Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions e2e/testcafe-devextreme/helpers/domUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const aiCreated = async (t: TestController): Promise<void> => {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export async function openAIDialog(
): Promise<HtmlEditor> {
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) {
Expand Down
53 changes: 53 additions & 0 deletions e2e/testcafe-devextreme/tests/editors/htmlEditor/keyboard.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>test</p>' });
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: '<p>test</p>' });
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: '<p>test</p>',
toolbar: { items: ['bold', 'italic'] },
});
await addFocusableElementBefore('#container');
await addFocusableElementAfter('#container');
});
123 changes: 123 additions & 0 deletions packages/devextreme/js/__internal/core/utils/focus.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@ts/core/utils/m_selectors')>('@ts/core/utils/m_selectors'),
isElementVisible: jest.fn(() => true),
}));

const add = <T extends HTMLElement>(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();
});
});
54 changes: 54 additions & 0 deletions packages/devextreme/js/__internal/core/utils/focus.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(ALL_FOCUSABLE_ELEMENTS_SELECTOR),
);

return candidates.find((candidate) => isElementVisible(candidate)) ?? null;
}

function getFocusableElementsOutside(
containerNode: HTMLElement,
doc: Document,
): HTMLElement[] {
return Array.from(
doc.querySelectorAll<HTMLElement>(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;
}
Loading
Loading