From 63162f476da6561c6365ddf86b39a8b43c8de423 Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 15 Jul 2026 00:32:51 +0300 Subject: [PATCH 1/9] Extract ALL_FOCUSABLE_ELEMENTS_SELECTOR --- .../js/__internal/core/utils/m_selectors.ts | 26 ++++++++++++++++++- .../grid_core/keyboard_navigation/const.ts | 20 +------------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/devextreme/js/__internal/core/utils/m_selectors.ts b/packages/devextreme/js/__internal/core/utils/m_selectors.ts index 493a3255b01a..da11f2b0c5df 100644 --- a/packages/devextreme/js/__internal/core/utils/m_selectors.ts +++ b/packages/devextreme/js/__internal/core/utils/m_selectors.ts @@ -1,6 +1,26 @@ import domAdapter from '@js/core/dom_adapter'; import $ from '@js/core/renderer'; +const notInert = ':not([inert]):not([inert] *)'; +const notNegTabIndex = ':not([tabindex^="-"])'; +const notDisabled = ':not(:disabled)'; + +export const ALL_FOCUSABLE_ELEMENTS_SELECTOR = [ + `a[href]${notInert}${notNegTabIndex}`, + `area[href]${notInert}${notNegTabIndex}`, + `input:not([type="hidden"]):not([type="radio"])${notInert}${notNegTabIndex}${notDisabled}`, + `input[type="radio"]${notInert}${notNegTabIndex}${notDisabled}`, + `select${notInert}${notNegTabIndex}${notDisabled}`, + `textarea${notInert}${notNegTabIndex}${notDisabled}`, + `button${notInert}${notNegTabIndex}${notDisabled}`, + `details${notInert} > summary:first-of-type${notNegTabIndex}`, + `iframe${notInert}${notNegTabIndex}`, + `audio[controls]${notInert}${notNegTabIndex}`, + `video[controls]${notInert}${notNegTabIndex}`, + `[contenteditable]${notInert}${notNegTabIndex}`, + `[tabindex]${notInert}${notNegTabIndex}`, +].join(','); + const focusableFn = (element, tabIndex) => { if (!visible(element)) { return false; @@ -29,6 +49,8 @@ function visible(element) { return $element.is(':visible') && $element.css('visibility') !== 'hidden' && $element.parents().css('visibility') !== 'hidden'; } +export const isElementVisible = (element: Element): boolean => visible(element); + export const focusable = (index, element) => focusableFn(element, $(element).attr('tabIndex')); export const tabbable = (index, element) => { const tabIndex = $(element).attr('tabIndex'); @@ -42,4 +64,6 @@ export const focused = ($element) => { return domAdapter.getActiveElement(element) === element; }; -export default { focusable, tabbable, focused }; +export default { + focusable, tabbable, focused, isElementVisible, ALL_FOCUSABLE_ELEMENTS_SELECTOR, +}; diff --git a/packages/devextreme/js/__internal/grids/new/grid_core/keyboard_navigation/const.ts b/packages/devextreme/js/__internal/grids/new/grid_core/keyboard_navigation/const.ts index 58b1f88bbb62..50cd7cd61bf5 100644 --- a/packages/devextreme/js/__internal/grids/new/grid_core/keyboard_navigation/const.ts +++ b/packages/devextreme/js/__internal/grids/new/grid_core/keyboard_navigation/const.ts @@ -1,19 +1 @@ -const notInert = ':not([inert]):not([inert] *)'; -const notNegTabIndex = ':not([tabindex^="-"])'; -const notDisabled = ':not(:disabled)'; - -export const ALL_FOCUSABLE_ELEMENTS_SELECTOR = [ - `a[href]${notInert}${notNegTabIndex}`, - `area[href]${notInert}${notNegTabIndex}`, - `input:not([type="hidden"]):not([type="radio"])${notInert}${notNegTabIndex}${notDisabled}`, - `input[type="radio"]${notInert}${notNegTabIndex}${notDisabled}`, - `select${notInert}${notNegTabIndex}${notDisabled}`, - `textarea${notInert}${notNegTabIndex}${notDisabled}`, - `button${notInert}${notNegTabIndex}${notDisabled}`, - `details${notInert} > summary:first-of-type${notNegTabIndex}`, - `iframe${notInert}${notNegTabIndex}`, - `audio[controls]${notInert}${notNegTabIndex}`, - `video[controls]${notInert}${notNegTabIndex}`, - `[contenteditable]${notInert}${notNegTabIndex}`, - `[tabindex]${notInert}${notNegTabIndex}`, -].join(','); +export { ALL_FOCUSABLE_ELEMENTS_SELECTOR } from '@ts/core/utils/m_selectors'; From 19a78b06e403aa4327449a7d0bc8badee1567024 Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 15 Jul 2026 00:34:43 +0300 Subject: [PATCH 2/9] Fix linter issue --- packages/devextreme/js/__internal/ui/popover/popover.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devextreme/js/__internal/ui/popover/popover.ts b/packages/devextreme/js/__internal/ui/popover/popover.ts index 6e5676cf2189..6697bd3b2b65 100644 --- a/packages/devextreme/js/__internal/ui/popover/popover.ts +++ b/packages/devextreme/js/__internal/ui/popover/popover.ts @@ -200,7 +200,7 @@ class Popover< const { visible } = this.option(); const overlayStack = this._overlayStack(); - const isTopOverlay = overlayStack[overlayStack.length - 1] === this; + const isTopOverlay = (overlayStack[overlayStack.length - 1] as unknown) === this; if (normalizeKeyName(e) === ESC_KEY_NAME && visible && isTopOverlay) { // eslint-disable-next-line @typescript-eslint/no-floating-promises From 06a4fafd9d8b2bf3c553da1bc9680a40f58fdc2e Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 15 Jul 2026 00:37:48 +0300 Subject: [PATCH 3/9] Core implementation --- .../js/__internal/core/utils/focus.ts | 54 +++++++++++++++++++ .../__internal/ui/html_editor/html_editor.ts | 39 ++++++++++++-- .../ui/html_editor/modules/m_toolbar.ts | 6 ++- .../js/__internal/ui/toolbar/toolbar.ts | 13 +++++ 4 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 packages/devextreme/js/__internal/core/utils/focus.ts diff --git a/packages/devextreme/js/__internal/core/utils/focus.ts b/packages/devextreme/js/__internal/core/utils/focus.ts new file mode 100644 index 000000000000..d40fae5eb2d2 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/focus.ts @@ -0,0 +1,54 @@ +import domAdapter from '@js/core/dom_adapter'; +import { ALL_FOCUSABLE_ELEMENTS_SELECTOR, isElementVisible } from '@ts/core/utils/m_selectors'; + +const DOCUMENT_POSITION_PRECEDING = 2; +const DOCUMENT_POSITION_FOLLOWING = 4; + +export function getFirstFocusableElement( + root: HTMLElement | null | undefined, +): HTMLElement | null { + if (!root) { + return null; + } + + const candidates = Array.from( + root.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR), + ); + + return candidates.find((candidate) => isElementVisible(candidate)) ?? null; +} + +function getFocusableElementsOutside( + containerNode: HTMLElement, + doc: Document, +): HTMLElement[] { + return Array.from( + doc.querySelectorAll(ALL_FOCUSABLE_ELEMENTS_SELECTOR), + ).filter((element) => !containerNode.contains(element) && isElementVisible(element)); +} + +export function getNextFocusableElement( + containerNode: HTMLElement, + doc: Document = domAdapter.getDocument(), +): HTMLElement | null { + return getFocusableElementsOutside(containerNode, doc) + .find((element) => { + const position = containerNode.compareDocumentPosition(element); + // eslint-disable-next-line no-bitwise + return Boolean(position & DOCUMENT_POSITION_FOLLOWING); + }) ?? null; +} + +export function getPreviousFocusableElement( + containerNode: HTMLElement, + doc: Document = domAdapter.getDocument(), +): HTMLElement | null { + const precedingElements = getFocusableElementsOutside(containerNode, doc) + .filter((element) => { + const position = containerNode.compareDocumentPosition(element); + // eslint-disable-next-line no-bitwise + return Boolean(position & DOCUMENT_POSITION_PRECEDING); + }); + + return precedingElements[precedingElements.length - 1] ?? null; +} diff --git a/packages/devextreme/js/__internal/ui/html_editor/html_editor.ts b/packages/devextreme/js/__internal/ui/html_editor/html_editor.ts index efaac05e9405..25f5da265b40 100644 --- a/packages/devextreme/js/__internal/ui/html_editor/html_editor.ts +++ b/packages/devextreme/js/__internal/ui/html_editor/html_editor.ts @@ -5,10 +5,11 @@ import { Event as dxEvent } from '@js/common/core/events'; import eventsEngine from '@js/common/core/events/core/events_engine'; import scrollEvents from '@js/common/core/events/gesture/emitter.gesture.scroll'; import pointerEvents from '@js/common/core/events/pointer'; -import { addNamespace } from '@js/common/core/events/utils/index'; +import { addNamespace, normalizeKeyName } from '@js/common/core/events/utils/index'; import registerComponent from '@js/core/component_registrator'; import config from '@js/core/config'; import devices from '@js/core/devices'; +import domAdapter from '@js/core/dom_adapter'; import { getPublicElement } from '@js/core/element'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; @@ -28,6 +29,7 @@ import { isDefined, isFunction } from '@js/core/utils/type'; import type { DxEvent } from '@js/events'; import type { Properties as FormProperties } from '@js/ui/form'; import type { Converter, HtmlEditorFormat, Properties } from '@js/ui/html_editor'; +import { getNextFocusableElement, getPreviousFocusableElement } from '@ts/core/utils/focus'; import type { OptionChanged } from '@ts/core/widget/types'; import type { ValueChangedEvent } from '@ts/ui/editor/editor'; import Editor from '@ts/ui/editor/editor'; @@ -357,6 +359,37 @@ class HtmlEditor extends Editor { _keyDownHandler(e: ValueChangedEvent): void { this._saveValueChangeEvent(e); + this._handleFocusEscape(e as unknown as KeyboardEvent); + } + + _handleFocusEscape(e: KeyboardEvent): void { + if (!e.ctrlKey || !e.shiftKey) { + return; + } + + const keyName = normalizeKeyName(e); + + if (keyName !== 'upArrow' && keyName !== 'downArrow') { + return; + } + + const isBackward = keyName === 'upArrow'; + + e.preventDefault(); + + if (isBackward && this._applyToolbarMethod('focusFirstItem')) { + return; + } + + const editorNode = this.$element().get(0) as HTMLElement; + const target = ( + isBackward + ? getPreviousFocusableElement(editorNode) + : getNextFocusableElement(editorNode) + ) + ?? domAdapter.getBody(); + + target.focus(); } _renderHtmlEditor(): void { @@ -771,8 +804,8 @@ class HtmlEditor extends Editor { } } - _applyToolbarMethod(methodName: string): void { - this.getModule('toolbar')?.[methodName](); + _applyToolbarMethod(methodName: string): unknown { + return this.getModule('toolbar')?.[methodName](); } addCleanCallback(callback: () => unknown): void { diff --git a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts index afc71c31e703..c8f5d32dd9d8 100644 --- a/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts +++ b/packages/devextreme/js/__internal/ui/html_editor/modules/m_toolbar.ts @@ -17,10 +17,10 @@ import { import type { AICommandName, AICustomCommand, AIToolbarItem } from '@js/ui/html_editor'; import type { ContentReadyEvent, ItemClickEvent } from '@js/ui/menu'; import type { Item } from '@js/ui/toolbar'; -import Toolbar from '@js/ui/toolbar'; import errors from '@js/ui/widget/ui.errors'; import { capitalize } from '@ts/core/utils/capitalize'; import { DX_MENU_ITEM_CLASS } from '@ts/ui/menu/menu'; +import Toolbar from '@ts/ui/toolbar/toolbar'; import Quill from 'devextreme-quill'; import type { CommandsMap } from '../utils/ai'; @@ -237,6 +237,10 @@ if (Quill) { this.toolbarInstance && this.toolbarInstance.repaint(); } + focusFirstItem(): boolean { + return this.toolbarInstance?.focusFirstItem() ?? false; + } + _getContainer() { const $container = $('
'); diff --git a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts index ae02af7499e3..1fe8dff5e135 100644 --- a/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts +++ b/packages/devextreme/js/__internal/ui/toolbar/toolbar.ts @@ -1,6 +1,7 @@ import registerComponent from '@js/core/component_registrator'; import type { dxElementWrapper } from '@js/core/renderer'; import type { Item } from '@js/ui/toolbar'; +import { getFirstFocusableElement } from '@ts/core/utils/focus'; import type { OptionChanged } from '@ts/core/widget/types'; import { MultiLineStrategy } from './strategy/toolbar.multiline'; @@ -160,6 +161,18 @@ class Toolbar extends ToolbarBase { this._getToolbarItems().forEach((item) => toggleItemFocusableElementTabIndex(this, item)); } + focusFirstItem(): boolean { + if (this.option('disabled')) { + return false; + } + + const target = getFirstFocusableElement(this.$element().get(0) as HTMLElement); + + target?.focus(); + + return Boolean(target); + } + _isMenuItem(itemData: Item): boolean { return itemData.locateInMenu === 'always'; } From 19e92851895fec92265111d952015af78f7a116c Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 15 Jul 2026 00:39:49 +0300 Subject: [PATCH 4/9] Refactor RootToolbar test model --- .../tests/accessibility/htmlEditor.ts | 2 +- .../tests/editors/htmlEditor/common.ts | 2 +- .../dialogs/addImage/addImageFromDevice.ts | 4 ++-- .../htmlEditor/dialogs/addImage/addImageUrl.ts | 10 +++++----- .../editors/htmlEditor/dialogs/addImage/common.ts | 12 ++++++------ .../editors/htmlEditor/dialogs/aiDialog/common.ts | 2 +- .../testcafe-models/htmlEditor/rootToolbar.ts | 7 ++++--- packages/testcafe-models/toolbar/toolbarItem.ts | 15 +++++++++++++++ 8 files changed, 35 insertions(+), 19 deletions(-) create mode 100644 packages/testcafe-models/toolbar/toolbarItem.ts diff --git a/e2e/testcafe-devextreme/tests/accessibility/htmlEditor.ts b/e2e/testcafe-devextreme/tests/accessibility/htmlEditor.ts index 612d4c145957..0f22a4d4287f 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/htmlEditor.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/htmlEditor.ts @@ -61,7 +61,7 @@ const aiCreated = async (t: TestController): Promise => { await t.click(Selector(defaultSelector)); await t - .click(htmlEditor.toolbar.getItemByName('ai')) + .click(htmlEditor.toolbar.getItemByName('ai').element) .click(Selector(`.${SUBMENU_CLASS} .${MENU_ITEM_CLASS}`).nth(4)); await t diff --git a/e2e/testcafe-devextreme/tests/editors/htmlEditor/common.ts b/e2e/testcafe-devextreme/tests/editors/htmlEditor/common.ts index 3943c19d719a..fbe4655f863b 100644 --- a/e2e/testcafe-devextreme/tests/editors/htmlEditor/common.ts +++ b/e2e/testcafe-devextreme/tests/editors/htmlEditor/common.ts @@ -60,7 +60,7 @@ test('AI toolbar item', async (t) => { await testScreenshot(t, takeScreenshot, 'htmleditor-ai-toolbar-item.png', { element: '#container' }); await t - .click(htmlEditor.toolbar.getItemByName('ai')) + .click(htmlEditor.toolbar.getItemByName('ai').element) .click(Selector(`.${SUBMENU_CLASS}`).find(`.${MENU_ITEM_CLASS}`).nth(5)); await testScreenshot(t, takeScreenshot, 'htmleditor-ai-toolbar-item-expanded.png', { element: '#container' }); diff --git a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageFromDevice.ts b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageFromDevice.ts index f56d98555d70..bdf32967abb2 100644 --- a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageFromDevice.ts +++ b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageFromDevice.ts @@ -14,7 +14,7 @@ test('Image from device should be inserted', async (t) => { const { takeScreenshot, compareResults } = createScreenshotsComparer(t); const htmlEditor = new HtmlEditor('#container'); - await t.click(htmlEditor.toolbar.getItemByName('image')); + await t.click(htmlEditor.toolbar.getItemByName('image').element); await t .expect(htmlEditor.dialog.footerToolbar.addButton.isDisabled) @@ -73,7 +73,7 @@ test('Image should be validated and inserted from device', async (t) => { const { takeScreenshot, compareResults } = createScreenshotsComparer(t); const htmlEditor = new HtmlEditor('#container'); - await t.click(htmlEditor.toolbar.getItemByName('image')); + await t.click(htmlEditor.toolbar.getItemByName('image').element); const { fileUploader } = htmlEditor.dialog.addImageFileForm; diff --git a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageUrl.ts b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageUrl.ts index 8e5bb86edbee..d182656550c1 100644 --- a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageUrl.ts +++ b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/addImageUrl.ts @@ -15,7 +15,7 @@ test('Image uploader from url appearance', async (t) => { const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('image')); + .click(htmlEditor.toolbar.getItemByName('image').element); await t .click(htmlEditor.dialog.addImageUrlForm.lockButton.element); @@ -38,7 +38,7 @@ test('Image url should be validate before wil be inserted by add button click', const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('image')) + .click(htmlEditor.toolbar.getItemByName('image').element) .click(htmlEditor.dialog.footerToolbar.addButton.element); await t @@ -71,7 +71,7 @@ test('Image url should be validate before wil be inserted by add enter press', a const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('image')); + .click(htmlEditor.toolbar.getItemByName('image').element); await t .pressKey('enter') @@ -104,7 +104,7 @@ test('Image url should be updated', async (t) => { const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('image')) + .click(htmlEditor.toolbar.getItemByName('image').element) .expect(htmlEditor.dialog.footerToolbar.addButton.text) .eql(isMaterial() ? 'ADD' : 'Add'); @@ -118,7 +118,7 @@ test('Image url should be updated', async (t) => { await testScreenshot(t, takeScreenshot, 'editor-add-url-image-before-updated.png', { element: htmlEditor.content }); await t - .click(htmlEditor.toolbar.getItemByName('image')) + .click(htmlEditor.toolbar.getItemByName('image').element) .expect(htmlEditor.dialog.footerToolbar.addButton.text) .eql(isMaterial() ? 'UPDATE' : 'Update'); diff --git a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/common.ts b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/common.ts index 94d4623ce7b3..9868e6e65619 100644 --- a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/common.ts +++ b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/addImage/common.ts @@ -16,7 +16,7 @@ test('TabPanel in HtmlEditor must have correct borders', async (t) => { const { takeScreenshot, compareResults } = createScreenshotsComparer(t); const htmlEditor = new HtmlEditor('#container'); - await t.click(htmlEditor.toolbar.getItemByName('image')); + await t.click(htmlEditor.toolbar.getItemByName('image').element); await testScreenshot(t, takeScreenshot, 'tabpanel-in-htmleditor.png', { element: ADD_IMAGE_POPUP_CONTENT_SELECTOR, @@ -40,7 +40,7 @@ test('Add button should be enabled after switch to url form', async (t) => { const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('image')) + .click(htmlEditor.toolbar.getItemByName('image').element) .expect(htmlEditor.dialog.footerToolbar.addButton.isDisabled) .eql(true); @@ -70,7 +70,7 @@ test('Add button should be disable after switch to image upload form', async (t) const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('image')) + .click(htmlEditor.toolbar.getItemByName('image').element) .expect(htmlEditor.dialog.footerToolbar.addButton.isDisabled) .notOk() @@ -109,7 +109,7 @@ test('AddImage form shouldn\'t lead to side effects in other forms', async (t) = const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('image')) + .click(htmlEditor.toolbar.getItemByName('image').element) .expect(htmlEditor.dialog.footerToolbar.addButton.isDisabled) .ok() @@ -120,7 +120,7 @@ test('AddImage form shouldn\'t lead to side effects in other forms', async (t) = .click(htmlEditor.dialog.footerToolbar.cancelButton.element); await t - .click(htmlEditor.toolbar.getItemByName('link')) + .click(htmlEditor.toolbar.getItemByName('link').element) .expect(htmlEditor.dialog.footerToolbar.addButton.isDisabled) .notOk() @@ -131,7 +131,7 @@ test('AddImage form shouldn\'t lead to side effects in other forms', async (t) = .click(htmlEditor.dialog.footerToolbar.addButton.element); await t - .click(htmlEditor.toolbar.getItemByName('color')) + .click(htmlEditor.toolbar.getItemByName('color').element) .expect(htmlEditor.dialog.footerToolbar.addButton.isDisabled) .notOk() diff --git a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/aiDialog/common.ts b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/aiDialog/common.ts index 90a346bc74a9..b0138f5ec8ae 100644 --- a/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/aiDialog/common.ts +++ b/e2e/testcafe-devextreme/tests/editors/htmlEditor/dialogs/aiDialog/common.ts @@ -26,7 +26,7 @@ export async function openAIDialog( ): Promise { const htmlEditor = new HtmlEditor('#container'); await t - .click(htmlEditor.toolbar.getItemByName('ai')) + .click(htmlEditor.toolbar.getItemByName('ai').element) .click(Selector(`.${SUBMENU_CLASS} .${MENU_ITEM_CLASS}`).nth(command)); if (option !== undefined) { diff --git a/packages/testcafe-models/htmlEditor/rootToolbar.ts b/packages/testcafe-models/htmlEditor/rootToolbar.ts index 31d504c8384b..14066c96a633 100644 --- a/packages/testcafe-models/htmlEditor/rootToolbar.ts +++ b/packages/testcafe-models/htmlEditor/rootToolbar.ts @@ -1,17 +1,18 @@ import Toolbar from '../toolbar'; +import ToolbarItem from '../toolbar/toolbarItem'; const CLASS = { ROOT: '.dx-htmleditor-toolbar', }; -type ToolbarItemName = 'image' | 'color' | 'link' | 'ai'; +type ToolbarItemName = 'image' | 'color' | 'link' | 'ai' | 'bold' | 'italic'; export default class RootToolbar extends Toolbar { constructor() { super(CLASS.ROOT); } - public getItemByName(itemName: ToolbarItemName): Selector { - return this.element.find(`.dx-${itemName}-format`).parent().parent(); + public getItemByName(itemName: ToolbarItemName): ToolbarItem { + return new ToolbarItem(this.element.find(`.dx-${itemName}-format`)); } } diff --git a/packages/testcafe-models/toolbar/toolbarItem.ts b/packages/testcafe-models/toolbar/toolbarItem.ts new file mode 100644 index 000000000000..6d92340aea31 --- /dev/null +++ b/packages/testcafe-models/toolbar/toolbarItem.ts @@ -0,0 +1,15 @@ +import CollectionItem from '../internal/collectionItem'; + +const CLASS = { + focused: 'dx-state-focused', +}; + +export default class ToolbarItem extends CollectionItem { + isFocused: Promise; + + constructor(element: Selector) { + super(element); + + this.isFocused = element.hasClass(CLASS.focused); + } +} From 9ed57f5dcd2bf06ea4d54511d83ad879a72ec56d Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 15 Jul 2026 00:40:20 +0300 Subject: [PATCH 5/9] Add e2e tests --- e2e/testcafe-devextreme/helpers/domUtils.ts | 15 ++++++ .../tests/editors/htmlEditor/keyboard.ts | 53 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 e2e/testcafe-devextreme/tests/editors/htmlEditor/keyboard.ts diff --git a/e2e/testcafe-devextreme/helpers/domUtils.ts b/e2e/testcafe-devextreme/helpers/domUtils.ts index 1c0b4f9570c7..af65b0fe4d56 100644 --- a/e2e/testcafe-devextreme/helpers/domUtils.ts +++ b/e2e/testcafe-devextreme/helpers/domUtils.ts @@ -137,6 +137,21 @@ export const addFocusableElementBefore = ClientFunction(( return button.id; }); +export const addFocusableElementAfter = ClientFunction(( + targetSelector: string, + elementId = 'focusable-end', +) => { + const existing = document.getElementById(elementId); + existing?.remove(); + + const target = document.querySelector(targetSelector); + const button = document.createElement('button'); + button.id = elementId; + button.textContent = 'End'; + target?.parentElement?.insertBefore(button, target.nextElementSibling); + return button.id; +}); + export const hasHorizontalScroll = ClientFunction((selector) => { const element = selector(); diff --git a/e2e/testcafe-devextreme/tests/editors/htmlEditor/keyboard.ts b/e2e/testcafe-devextreme/tests/editors/htmlEditor/keyboard.ts new file mode 100644 index 000000000000..5c5621ab61f3 --- /dev/null +++ b/e2e/testcafe-devextreme/tests/editors/htmlEditor/keyboard.ts @@ -0,0 +1,53 @@ +import { Selector } from 'testcafe'; +import HtmlEditor from 'devextreme-testcafe-models/htmlEditor'; +import url from '../../../helpers/getPageUrl'; +import { createWidget } from '../../../helpers/createWidget'; +import { addFocusableElementBefore, addFocusableElementAfter } from '../../../helpers/domUtils'; + +fixture`HtmlEditor - focus escape` + .page(url(__dirname, '../../container-extended.html')); + +test('Ctrl+Shift+Down should move focus to the next focusable element outside the editor', async (t) => { + const htmlEditor = new HtmlEditor('#container'); + + await t + .click(htmlEditor.content) + .pressKey('ctrl+shift+down') + .expect(Selector('#focusable-end').focused) + .ok(); +}).before(async () => { + await createWidget('dxHtmlEditor', { value: '

test

' }); + await addFocusableElementBefore('#container'); + await addFocusableElementAfter('#container'); +}); + +test('Ctrl+Shift+Up should move focus to the previous focusable element when there is no toolbar', async (t) => { + const htmlEditor = new HtmlEditor('#container'); + + await t + .click(htmlEditor.content) + .pressKey('ctrl+shift+up') + .expect(Selector('#focusable-start').focused) + .ok(); +}).before(async () => { + await createWidget('dxHtmlEditor', { value: '

test

' }); + await addFocusableElementBefore('#container'); + await addFocusableElementAfter('#container'); +}); + +test('Ctrl+Shift+Up should move focus into the toolbar when it is present', async (t) => { + const htmlEditor = new HtmlEditor('#container'); + + await t + .click(htmlEditor.content) + .pressKey('ctrl+shift+up') + .expect(htmlEditor.toolbar.getItemByName('bold').isFocused) + .ok(); +}).before(async () => { + await createWidget('dxHtmlEditor', { + value: '

test

', + toolbar: { items: ['bold', 'italic'] }, + }); + await addFocusableElementBefore('#container'); + await addFocusableElementAfter('#container'); +}); From 005f3511005ab49cd564e48980eb7fe1d201df85 Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 15 Jul 2026 00:44:35 +0300 Subject: [PATCH 6/9] Focus utils tests --- .../js/__internal/core/utils/focus.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 packages/devextreme/js/__internal/core/utils/focus.test.ts diff --git a/packages/devextreme/js/__internal/core/utils/focus.test.ts b/packages/devextreme/js/__internal/core/utils/focus.test.ts new file mode 100644 index 000000000000..170572caa4a2 --- /dev/null +++ b/packages/devextreme/js/__internal/core/utils/focus.test.ts @@ -0,0 +1,123 @@ +import { + describe, expect, it, jest, +} from '@jest/globals'; + +import { + getFirstFocusableElement, + getNextFocusableElement, + getPreviousFocusableElement, +} from './focus'; + +jest.mock('@ts/core/utils/m_selectors', () => ({ + ...jest.requireActual('@ts/core/utils/m_selectors'), + isElementVisible: jest.fn(() => true), +})); + +const add = (element: T, parent: Node = document.body): T => { + parent.appendChild(element); + return element; +}; + +const button = (id: string): HTMLButtonElement => { + const el = document.createElement('button'); + el.id = id; + return el; +}; + +const setup = (): { + before: HTMLButtonElement; + container: HTMLDivElement; + after: HTMLButtonElement; +} => { + document.body.innerHTML = ''; + const before = add(button('before')); + const container = add(document.createElement('div')); + const after = add(button('after')); + return { before, container, after }; +}; + +describe('getNextFocusableElement', () => { + it('returns the next focusable after the container', () => { + const { container, after } = setup(); + + expect(getNextFocusableElement(container)).toBe(after); + }); + + it('returns the closest neighbor when several elements are present', () => { + const { container, after } = setup(); + const farAfter = add(button('far-after')); + + expect(getNextFocusableElement(container)).toBe(after); + expect(getNextFocusableElement(container)).not.toBe(farAfter); + }); + + it('skips focusable elements located inside the container', () => { + const { container, after } = setup(); + add(button('inside'), container); + + expect(getNextFocusableElement(container)).toBe(after); + }); + + it('returns null when there is no focusable element after the container', () => { + const { container, after } = setup(); + after.remove(); + + expect(getNextFocusableElement(container)).toBeNull(); + }); +}); + +describe('getPreviousFocusableElement', () => { + it('returns the previous focusable before the container', () => { + const { before, container } = setup(); + + expect(getPreviousFocusableElement(container)).toBe(before); + }); + + it('returns the closest neighbor when several elements are present', () => { + const { before, container } = setup(); + const farBefore = add(button('far-before'), document.body); + document.body.insertBefore(farBefore, before); + + expect(getPreviousFocusableElement(container)).toBe(before); + expect(getPreviousFocusableElement(container)).not.toBe(farBefore); + }); + + it('skips focusable elements located inside the container', () => { + const { before, container } = setup(); + add(button('inside'), container); + + expect(getPreviousFocusableElement(container)).toBe(before); + }); + + it('returns null when there is no focusable element before the container', () => { + const { before, container } = setup(); + before.remove(); + + expect(getPreviousFocusableElement(container)).toBeNull(); + }); +}); + +describe('getFirstFocusableElement', () => { + it('returns the first focusable element inside the root', () => { + document.body.innerHTML = ''; + const root = add(document.createElement('div')); + add(document.createElement('span'), root); + const first = add(button('first'), root); + add(button('second'), root); + + expect(getFirstFocusableElement(root)).toBe(first); + }); + + it('returns null when the root has no focusable elements', () => { + document.body.innerHTML = ''; + const root = add(document.createElement('div')); + add(document.createElement('span'), root); + + expect(getFirstFocusableElement(root)).toBeNull(); + }); + + it('returns null when the root is null or undefined', () => { + expect(getFirstFocusableElement(null)).toBeNull(); + expect(getFirstFocusableElement(undefined)).toBeNull(); + }); +}); From e9d1b2359822f3ea2b40fffdbca40ef326b7fbe3 Mon Sep 17 00:00:00 2001 From: Raushen Date: Wed, 15 Jul 2026 00:45:31 +0300 Subject: [PATCH 7/9] KBN unit tests --- .../__tests__/__mock__/model/html_editor.ts | 35 ++++++ .../__tests__/focus_escape.test.ts | 102 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 packages/devextreme/js/__internal/ui/__tests__/__mock__/model/html_editor.ts create mode 100644 packages/devextreme/js/__internal/ui/html_editor/__tests__/focus_escape.test.ts diff --git a/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/html_editor.ts b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/html_editor.ts new file mode 100644 index 000000000000..96f9355e0505 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/__tests__/__mock__/model/html_editor.ts @@ -0,0 +1,35 @@ +const CLASSES = { + content: 'dx-htmleditor-content', + toolbarWrapper: 'dx-htmleditor-toolbar-wrapper', +}; + +export class HtmlEditorModel { + constructor(protected readonly root: HTMLElement) {} + + public getContent(): HTMLElement { + return this.root.querySelector(`.${CLASSES.content}`) as HTMLElement; + } + + public getToolbarWrapper(): HTMLElement { + return this.root.querySelector(`.${CLASSES.toolbarWrapper}`) as HTMLElement; + } + + public moveFocusFromContent(direction: 'up' | 'down'): KeyboardEvent { + const key = direction === 'up' ? 'ArrowUp' : 'ArrowDown'; + + return this.pressKeyInContent(key, { ctrlKey: true, shiftKey: true }); + } + + public pressKeyInContent( + key: string, + { ctrlKey = false, shiftKey = false }: { ctrlKey?: boolean; shiftKey?: boolean } = {}, + ): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + key, ctrlKey, shiftKey, bubbles: true, cancelable: true, + }); + + this.getContent().dispatchEvent(event); + + return event; + } +} diff --git a/packages/devextreme/js/__internal/ui/html_editor/__tests__/focus_escape.test.ts b/packages/devextreme/js/__internal/ui/html_editor/__tests__/focus_escape.test.ts new file mode 100644 index 000000000000..ce3db2c637a8 --- /dev/null +++ b/packages/devextreme/js/__internal/ui/html_editor/__tests__/focus_escape.test.ts @@ -0,0 +1,102 @@ +import { + afterEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { HtmlEditorModel } from '@ts/ui/__tests__/__mock__/model/html_editor'; + +import HtmlEditor from '../html_editor'; + +jest.mock('@ts/core/utils/m_selectors', () => ({ + ...jest.requireActual('@ts/core/utils/m_selectors'), + isElementVisible: jest.fn(() => true), +})); + +const editors: HtmlEditor[] = []; + +const createEditor = (options = {}): HtmlEditorModel => { + const $element = $('
').appendTo(document.body); + // @ts-expect-error direct instantiation is not typed + const instance = new HtmlEditor($element, options); + + editors.push(instance); + + return new HtmlEditorModel($element.get(0) as HTMLElement); +}; + +describe('HtmlEditor focus escape (Ctrl+Shift+Up/Down)', () => { + afterEach(() => { + editors.forEach((instance) => instance.dispose()); + editors.length = 0; + document.body.innerHTML = ''; + }); + + it('Ctrl+Shift+Down moves focus to the next focusable outside the editor', () => { + const before = $('