diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts index e21abaa877b1..52eb70bcf134 100644 --- a/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts +++ b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts @@ -206,7 +206,6 @@ const cellStyles = '#container .dx-scheduler-cell-sizes-vertical { height: 100px .click(scheduler.getAppointment('[Appointment 1]').element) .pressKey('tab') .click(scheduler.toolbar.viewSwitcher.element) - .pressKey('tab') .pressKey('tab'); await t @@ -251,7 +250,6 @@ test('should focus first visible appointment on tab (virtual scrolling)', async await t .scroll(scheduler.workspaceScrollable, 0, 1000) .click(scheduler.toolbar.viewSwitcher.element) - .pressKey('tab') .pressKey('tab'); await t @@ -267,7 +265,6 @@ test('should focus first rendered appointment on tab (standard scrolling)', asyn await t .scroll(scheduler.workspaceScrollable, 0, 1000) .click(scheduler.toolbar.viewSwitcher.element) - .pressKey('tab') .pressKey('tab'); await t diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts index 77b55c610faf..342e22d4a995 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts @@ -34,6 +34,26 @@ export class SchedulerModel { return new ToolbarModel(this.queries.getByRole('toolbar')); } + getHeader(): HTMLElement { + const result = this.container.querySelector('.dx-scheduler-header'); + + if (!result) { + throw new Error('Scheduler header element not found'); + } + + return result as HTMLElement; + } + + getWorkSpace(): HTMLElement { + const result = this.container.querySelector('.dx-scheduler-work-space'); + + if (!result) { + throw new Error('Scheduler workspace element not found'); + } + + return result as HTMLElement; + } + getStatusContent(): string { const statusElement = this.container.querySelector('.dx-screen-reader-only'); return statusElement?.textContent ?? ''; diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/header.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/header.test.ts new file mode 100644 index 000000000000..3df5c866140e --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/header.test.ts @@ -0,0 +1,59 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import $ from '@js/core/renderer'; + +import { createScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/mock_scheduler'; + +describe('Header', () => { + beforeEach(() => { + setupSchedulerTestEnvironment(); + }); + + afterEach(() => { + const $scheduler = $(document.querySelector('.dx-scheduler')); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; + }); + + it('should not have tabIndex attr', async () => { + const { POM } = await createScheduler({ + dataSource: [], + currentView: 'day', + currentDate: new Date(2021, 4, 24), + }); + + expect(POM.getHeader().hasAttribute('tabindex')).toBe(false); + }); + + it('should not have tabIndex attr after tabIndex option change', async () => { + const { scheduler, POM } = await createScheduler({ + dataSource: [], + currentView: 'day', + currentDate: new Date(2021, 4, 24), + }); + + scheduler.option('tabIndex', 1); + + expect(POM.getHeader().hasAttribute('tabindex')).toBe(false); + }); + + describe('Toolbar', () => { + it('should have viewSwitcher with locateInMenu: "auto" by default', async () => { + const { scheduler } = await createScheduler({ + dataSource: [], + currentView: 'day', + currentDate: new Date(2021, 4, 24), + }); + + const toolbarItems = scheduler.option('toolbar.items') as any[]; + const viewSwitcherItem = toolbarItems.find((item: any) => item.name === 'viewSwitcher'); + + expect(viewSwitcherItem).toBeDefined(); + expect(viewSwitcherItem.location).toBe('after'); + expect(viewSwitcherItem.locateInMenu).toBe('auto'); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/toolbar_adaptivity.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/toolbar_adaptivity.test.ts deleted file mode 100644 index 749356e250e6..000000000000 --- a/packages/devextreme/js/__internal/scheduler/__tests__/toolbar_adaptivity.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { - describe, expect, it, -} from '@jest/globals'; - -import { createScheduler } from './__mock__/create_scheduler'; -import { setupSchedulerTestEnvironment } from './__mock__/mock_scheduler'; - -describe('Toolbar Adaptivity', () => { - it('should have viewSwitcher with locateInMenu: "auto" by default', async () => { - setupSchedulerTestEnvironment(); - const { scheduler } = await createScheduler({ - dataSource: [], - currentView: 'day', - currentDate: new Date(2021, 4, 24), - }); - - const toolbarItems = scheduler.option('toolbar.items') as any[]; - const viewSwitcherItem = toolbarItems.find((item: any) => item.name === 'viewSwitcher'); - - expect(viewSwitcherItem).toBeDefined(); - expect(viewSwitcherItem.location).toBe('after'); - expect(viewSwitcherItem.locateInMenu).toBe('auto'); - }); -}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts deleted file mode 100644 index a60d7d71489f..000000000000 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - afterEach, beforeEach, describe, expect, it, -} from '@jest/globals'; -import $ from '@js/core/renderer'; - -import fx from '../../../common/core/animation/fx'; -import CustomStore from '../../../data/custom_store'; -import { createScheduler } from './__mock__/create_scheduler'; -import { setupSchedulerTestEnvironment } from './__mock__/mock_scheduler'; - -const CLASSES = { - scheduler: 'dx-scheduler', - workSpace: 'dx-scheduler-work-space', -}; - -describe('Workspace Recalculation with Async Templates (T661335)', () => { - beforeEach(() => { - fx.off = true; - setupSchedulerTestEnvironment({ height: 600 }); - }); - - afterEach(() => { - const $scheduler = $(document.querySelector(`.${CLASSES.scheduler}`)); - // @ts-expect-error - $scheduler.dxScheduler('dispose'); - document.body.innerHTML = ''; - fx.off = false; - }); - - it('should not duplicate workspace elements when resources are loaded asynchronously (T661335)', async () => { - const { scheduler, container } = await createScheduler({ - templatesRenderAsynchronously: true, - currentView: 'day', - views: ['day'], - groups: ['owner'], - resources: [ - { - fieldExpr: 'owner', - dataSource: [{ id: 1, text: 'Owner 1' }], - }, - { - fieldExpr: 'room', - dataSource: new CustomStore({ - load(): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve([{ id: 1, text: 'Room 1', color: '#ff0000' }]); - }); - }); - }, - }), - }, - ], - dataSource: [ - { - text: 'Meeting in Room 1', - startDate: new Date(2017, 4, 25, 9, 0), - endDate: new Date(2017, 4, 25, 10, 0), - roomId: 1, - }, - ], - startDayHour: 9, - currentDate: new Date(2017, 4, 25), - height: 600, - }); - - scheduler.option('groups', ['room']); - - await new Promise((r) => { setTimeout(r); }); - - const $workSpaces = $(container).find(`.${CLASSES.workSpace}`); - const $groupHeader = $(container).find('.dx-scheduler-group-header'); - - expect($workSpaces.length).toBe(1); - - expect($groupHeader.length).toBeGreaterThan(0); - expect($groupHeader.text()).toContain('Room 1'); - }); -}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts new file mode 100644 index 000000000000..c8c29f175932 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -0,0 +1,153 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import { fireEvent } from '@testing-library/dom'; + +import fx from '../../../common/core/animation/fx'; +import CustomStore from '../../../data/custom_store'; +import { createScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/mock_scheduler'; + +const CLASSES = { + scheduler: 'dx-scheduler', + workSpace: 'dx-scheduler-work-space', + focusedState: 'dx-state-focused', +}; + +const defaultOptions = { + currentView: 'week', + views: ['week'], + currentDate: new Date(2024, 0, 1), + startDayHour: 9, + endDayHour: 16, + height: 600, +}; + +describe('Workspace', () => { + beforeEach(() => { + fx.off = true; + setupSchedulerTestEnvironment({ height: 600 }); + }); + + afterEach(() => { + const $scheduler = $(document.querySelector(`.${CLASSES.scheduler}`)); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; + fx.off = false; + }); + + it('should not duplicate workspace elements when resources are loaded asynchronously (T661335)', async () => { + const { scheduler, container } = await createScheduler({ + templatesRenderAsynchronously: true, + currentView: 'day', + views: ['day'], + groups: ['owner'], + resources: [ + { + fieldExpr: 'owner', + dataSource: [{ id: 1, text: 'Owner 1' }], + }, + { + fieldExpr: 'room', + dataSource: new CustomStore({ + load(): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve([{ id: 1, text: 'Room 1', color: '#ff0000' }]); + }); + }); + }, + }), + }, + ], + dataSource: [ + { + text: 'Meeting in Room 1', + startDate: new Date(2017, 4, 25, 9, 0), + endDate: new Date(2017, 4, 25, 10, 0), + roomId: 1, + }, + ], + startDayHour: 9, + currentDate: new Date(2017, 4, 25), + height: 600, + }); + + scheduler.option('groups', ['room']); + + await new Promise((r) => { setTimeout(r); }); + + const $workSpaces = $(container).find(`.${CLASSES.workSpace}`); + const $groupHeader = $(container).find('.dx-scheduler-group-header'); + + expect($workSpaces.length).toBe(1); + + expect($groupHeader.length).toBeGreaterThan(0); + expect($groupHeader.text()).toContain('Room 1'); + }); + + describe('A11y', () => { + it('should have tabIndex -1 to be skipped in the tab order', async () => { + const { POM } = await createScheduler(defaultOptions); + + expect(POM.getWorkSpace().getAttribute('tabindex')).toBe('-1'); + }); + + it('should have tabIndex -1 after tabIndex option change', async () => { + const { scheduler, POM } = await createScheduler(defaultOptions); + + scheduler.option('tabIndex', 1); + + expect(POM.getWorkSpace().getAttribute('tabindex')).toBe('-1'); + }); + }); + + describe('Keyboard navigation', () => { + it('should focus the clicked cell', async () => { + const { POM } = await createScheduler(defaultOptions); + + const cell = POM.getDateTableCell(0, 0); + fireEvent.mouseDown(cell, { which: 1 }); + fireEvent.mouseUp(cell); + + expect(cell.classList.contains(CLASSES.focusedState)).toBe(true); + }); + + it('should move focus and selection to the next cell on arrow key', async () => { + const { POM } = await createScheduler(defaultOptions); + + const firstCell = POM.getDateTableCell(0, 0); + const secondCell = POM.getDateTableCell(1, 0); + fireEvent.mouseDown(firstCell, { which: 1 }); + fireEvent.mouseUp(firstCell); + fireEvent.keyDown(POM.getWorkSpace(), { key: 'ArrowDown' }); + + expect(secondCell.classList.contains(CLASSES.focusedState)).toBe(true); + expect(firstCell.classList.contains(CLASSES.focusedState)).toBe(false); + }); + + it('should extend selection on shift + arrow key', async () => { + const { scheduler, POM } = await createScheduler(defaultOptions); + + const firstCell = POM.getDateTableCell(0, 0); + fireEvent.mouseDown(firstCell, { which: 1 }); + fireEvent.mouseUp(firstCell); + fireEvent.keyDown(POM.getWorkSpace(), { key: 'ArrowDown', shiftKey: true }); + + expect(scheduler.option('selectedCellData')).toHaveLength(2); + }); + + it('should clear focused cell when focus leaves the workspace', async () => { + const { POM } = await createScheduler(defaultOptions); + + const cell = POM.getDateTableCell(0, 0); + fireEvent.mouseDown(cell, { which: 1 }); + fireEvent.mouseUp(cell); + fireEvent.focusOut(POM.getWorkSpace()); + + expect(cell.classList.contains(CLASSES.focusedState)).toBe(false); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/header/header.ts b/packages/devextreme/js/__internal/scheduler/header/header.ts index c3409749314b..56229bbd5ae3 100644 --- a/packages/devextreme/js/__internal/scheduler/header/header.ts +++ b/packages/devextreme/js/__internal/scheduler/header/header.ts @@ -4,6 +4,7 @@ import registerComponent from '@js/core/component_registrator'; import devices from '@js/core/devices'; import errors from '@js/core/errors'; import $ from '@js/core/renderer'; +import { noop } from '@js/core/utils/common'; import { getPathParts } from '@js/core/utils/data'; import dateUtils from '@js/core/utils/date'; import { extend } from '@js/core/utils/extend'; @@ -146,6 +147,8 @@ export class SchedulerHeader extends Widget { this._toggleVisibility(); } + _renderFocusTarget(): void { return noop(); } + private renderToolbar(): void { const config = this.createToolbarConfig(); diff --git a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts index 9c8daf34b6da..7655a5a89327 100644 --- a/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts +++ b/packages/devextreme/js/__internal/scheduler/workspaces/work_space.ts @@ -801,6 +801,10 @@ class SchedulerWorkSpace extends Widget { return this.$element(); } + _renderFocusTarget(): void { + this._focusTarget().attr('tabIndex', -1); + } + protected isVerticalGroupedWorkSpace(): boolean { // TODO move to the Model return Boolean(this.option().groups?.length) && this.option().groupOrientation === 'vertical'; } diff --git a/packages/devextreme/testing/helpers/keyboardMock.js b/packages/devextreme/testing/helpers/keyboardMock.js index 1f9c1ef2d051..ade1eefd1974 100644 --- a/packages/devextreme/testing/helpers/keyboardMock.js +++ b/packages/devextreme/testing/helpers/keyboardMock.js @@ -149,7 +149,7 @@ let focused; const isEditableElement = function() { const editableInputTypesRE = /^(date|datetime|datetime-local|email|month|number|password|search|tel|text|time|url|week)$/; - return $element.is('input') && editableInputTypesRE.test($element.prop('type')) || $element.is('textarea') || ($element.prop('tabindex') >= 0); + return $element.is('input') && editableInputTypesRE.test($element.prop('type')) || $element.is('textarea') || ($element.prop('tabindex') >= 0) || $element.attr('tabindex') !== undefined; }; const deleteSelection = function() {