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/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/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'); +}); 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(); + }); +}); 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/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'; 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 = $('