diff --git a/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts b/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts index 072aafdf3f48..745573effa48 100644 --- a/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts +++ b/e2e/testcafe-devextreme/tests/accessibility/scheduler/scheduler.ts @@ -13,7 +13,7 @@ test('Scheduler should have right aria attributes after view changed', async (t) await t.expect(scheduler.element.getAttribute('aria-label')).contains('Scheduler. Month view'); await t.expect(scheduler.getGeneralStatusContainer().textContent).contains('Scheduler. Month view'); - await t.expect(scheduler.element.getAttribute('role')).eql('group'); + await t.expect(scheduler.element.getAttribute('role')).eql('application'); await scheduler.option('currentView', 'week'); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts index 06ec245aaac9..3298956f94aa 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/appointment.ts @@ -11,6 +11,7 @@ export interface AppointmentModel { getGeometry: () => Position; getColor: (view: string) => string | undefined; getSnapshot: () => object; + isFocused: () => boolean; } const getColor = (appointment: HTMLDivElement): string => appointment.style.backgroundColor; @@ -60,4 +61,5 @@ export const createAppointmentModel = ( date: getDisplayDate(element), ...getGeometry(element), }), + isFocused: () => element?.classList.contains('dx-state-focused') ?? false, }); 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 7ef97ce50136..4fdc1ba26447 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts @@ -29,11 +29,19 @@ export class SchedulerModel { return new ToolbarModel(this.queries.getByRole('toolbar')); } + getHeader(): HTMLElement { + return this.container.querySelector('.dx-scheduler-header') as HTMLElement; + } + getStatusContent(): string { const statusElement = this.container.querySelector('.dx-screen-reader-only'); return statusElement?.textContent ?? ''; } + getWorkSpace(): HTMLElement { + return this.container.querySelector('.dx-scheduler-work-space') as HTMLElement; + } + getAppointment(text?: string): AppointmentModel { if (!text) { const appointments = this.getAppointments(); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts index e50e51e78830..87c3852a9d3d 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts @@ -1,14 +1,20 @@ import { afterEach, describe, expect, it, jest, } from '@jest/globals'; +import $ from '@js/core/renderer'; import { createScheduler } from './__mock__/create_scheduler'; import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; describe('Appointments', () => { afterEach(() => { + const $scheduler = $('.dx-scheduler'); + // @ts-expect-error + $scheduler.dxScheduler('dispose'); + document.body.innerHTML = ''; jest.useRealTimers(); }); + it('All-day appointment should not be resizable if current view is "day"', async () => { setupSchedulerTestEnvironment(); const { POM } = await createScheduler({ @@ -81,4 +87,96 @@ describe('Appointments', () => { expect(tooltipTitleElement?.textContent?.trim()).toBe('(No subject)'); } }); + + describe('Keyboard Navigation', () => { + const dataSource = [ + { + text: 'Appointment 1', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }, + { + text: 'Appointment 2', + startDate: new Date(2015, 1, 9, 10), + endDate: new Date(2015, 1, 9, 11), + }, + { + text: 'Appointment 3', + startDate: new Date(2015, 1, 9, 12), + endDate: new Date(2015, 1, 9, 13), + }, + ]; + + it('should focus first appointment on Home', async () => { + setupSchedulerTestEnvironment(); + const { POM, keydown } = await createScheduler({ + dataSource, + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointments = POM.getAppointments(); + const firstAppointment = appointments[0]; + const lastAppointment = appointments[2]; + + lastAppointment.element.focus(); + keydown(lastAppointment.element, 'Home'); + + expect(firstAppointment.isFocused()).toBe(true); + expect(lastAppointment.isFocused()).toBe(false); + }); + + it('should focus last appointment on End', async () => { + setupSchedulerTestEnvironment(); + const { POM, keydown } = await createScheduler({ + dataSource, + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointments = POM.getAppointments(); + const firstAppointment = appointments[0]; + const lastAppointment = appointments[2]; + + firstAppointment.element.focus(); + keydown(firstAppointment.element, 'End'); + + expect(firstAppointment.isFocused()).toBe(false); + expect(lastAppointment.isFocused()).toBe(true); + }); + + it('should not change focus when Home is pressed on the first appointment', async () => { + setupSchedulerTestEnvironment(); + const { POM, keydown } = await createScheduler({ + dataSource, + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointments = POM.getAppointments(); + const firstAppointment = appointments[0]; + + firstAppointment.element.focus(); + keydown(firstAppointment.element, 'Home'); + + expect(firstAppointment.isFocused()).toBe(true); + }); + + it('should not change focus when End is pressed on the last appointment', async () => { + setupSchedulerTestEnvironment(); + const { POM, keydown } = await createScheduler({ + dataSource, + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointments = POM.getAppointments(); + const lastAppointment = appointments[2]; + + lastAppointment.element.focus(); + keydown(lastAppointment.element, 'End'); + + expect(lastAppointment.isFocused()).toBe(true); + }); + }); }); 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..cf6d86d774b6 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/header.test.ts @@ -0,0 +1,67 @@ +import { + afterEach, beforeEach, describe, expect, it, +} from '@jest/globals'; +import $ from '@js/core/renderer'; + +import fx from '../../../common/core/animation/fx'; +import { createScheduler } from './__mock__/create_scheduler'; +import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; + +const CLASSES = { + scheduler: 'dx-scheduler', +}; + +describe('Header', () => { + 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 have tabIndex', async () => { + const { POM } = await createScheduler({ + dataSource: [], + currentView: 'day', + currentDate: new Date(2021, 4, 24), + }); + + expect(POM.getHeader().hasAttribute('tabindex')).toBeFalsy(); + }); + + it('should not have tabIndex after option change', async () => { + const { scheduler, POM } = await createScheduler({ + dataSource: [], + currentView: 'day', + currentDate: new Date(2021, 4, 24), + }); + + scheduler.option('tabIndex', 0); + + expect(POM.getHeader().hasAttribute('tabindex')).toBeFalsy(); + }); + + describe('Toolbar', () => { + 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__/toolbar_adaptivity.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/toolbar_adaptivity.test.ts deleted file mode 100644 index 39cd9daee62a..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__/m_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.test.ts similarity index 73% rename from packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts rename to packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts index 13ffa2259716..483bf5880c3e 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -10,10 +10,9 @@ import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; const CLASSES = { scheduler: 'dx-scheduler', - workSpace: 'dx-scheduler-work-space', }; -describe('Workspace Recalculation with Async Templates (T661335)', () => { +describe('Workspace', () => { beforeEach(() => { fx.off = true; setupSchedulerTestEnvironment({ height: 600 }); @@ -28,7 +27,7 @@ describe('Workspace Recalculation with Async Templates (T661335)', () => { }); it('should not duplicate workspace elements when resources are loaded asynchronously (T661335)', async () => { - const { scheduler, container } = await createScheduler({ + const { scheduler, container, POM } = await createScheduler({ templatesRenderAsynchronously: true, currentView: 'day', views: ['day'], @@ -68,7 +67,7 @@ describe('Workspace Recalculation with Async Templates (T661335)', () => { await new Promise((r) => { setTimeout(r); }); - const $workSpaces = $(container).find(`.${CLASSES.workSpace}`); + const $workSpaces = $(POM.getWorkSpace()); const $groupHeader = $(container).find('.dx-scheduler-group-header'); expect($workSpaces.length).toBe(1); @@ -76,4 +75,26 @@ describe('Workspace Recalculation with Async Templates (T661335)', () => { expect($groupHeader.length).toBeGreaterThan(0); expect($groupHeader.text()).toContain('Room 1'); }); + + it('should not have tabIndex attr', async () => { + const { POM } = await createScheduler({ + currentView: 'day', + views: ['day'], + currentDate: new Date(2017, 4, 25), + }); + + expect(POM.getWorkSpace().hasAttribute('tabindex')).toBeFalsy(); + }); + + it('should not have tabIndex attr after option change', async () => { + const { scheduler, POM } = await createScheduler({ + currentView: 'day', + views: ['day'], + currentDate: new Date(2017, 4, 25), + }); + + scheduler.option('tabIndex', 1); + + expect(POM.getWorkSpace().hasAttribute('tabindex')).toBeFalsy(); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts b/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts index 88124bb7beb2..9e6788d0827b 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts @@ -59,6 +59,8 @@ export class AppointmentsKeyboardNavigation { escape: this.escHandler.bind(this), del: this.delHandler.bind(this), tab: this.tabHandler.bind(this), + home: this.homeHandler.bind(this), + end: this.endHandler.bind(this), }; } @@ -87,8 +89,7 @@ export class AppointmentsKeyboardNavigation { $nextAppointment = this.getFocusableItemBySortedIndex(index); } - this.resetTabIndex($nextAppointment); - eventsEngine.trigger($nextAppointment, 'focus'); + this.focusItem($nextAppointment); } } @@ -115,4 +116,29 @@ export class AppointmentsKeyboardNavigation { resizableInstance._toggleResizingClass(false); } } + + private homeHandler(): void { + const $firstItem = this.getFocusableItems().first(); + + if (this.$focusedItem && $firstItem.is(this.$focusedItem)) { + return; + } + + this.focusItem($firstItem); + } + + private endHandler(): void { + const $lastItem = this.getFocusableItems().last(); + + if (this.$focusedItem && $lastItem.is(this.$focusedItem)) { + return; + } + + this.focusItem($lastItem); + } + + private focusItem($item: dxElementWrapper): void { + this.resetTabIndex($item); + eventsEngine.trigger($item, 'focus'); + } } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 0c635929bded..02165904d1a2 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -428,6 +428,10 @@ class Scheduler extends SchedulerOptionsBaseWidget { } break; case 'tabIndex': + this._appointments.option(name, value); + // @ts-expect-error + super._optionChanged(args); + break; case 'focusStateEnabled': this._updateOption('header', name, value); this._updateOption('workSpace', name, value); @@ -991,7 +995,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { this._a11yStatus = createA11yStatusContainer(); this._a11yStatus.prependTo(this.$element()); // @ts-expect-error - this.setAria({ role: 'group' }); + this.setAria({ role: 'application' }); } _initMarkupOnResourceLoaded() { @@ -1227,7 +1231,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { max: this.getViewOption('max'), indicatorTime: this.option('indicatorTime'), startViewDate: this.getStartViewDate(), - tabIndex: this.option('tabIndex'), + tabIndex: undefined, focusStateEnabled: this.option('focusStateEnabled'), useDropDownViewSwitcher: this.option('useDropDownViewSwitcher'), firstDayOfWeek: this.getFirstDayOfWeek(), @@ -1356,7 +1360,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { startDayHour: this.option('startDayHour'), endDayHour: this.option('endDayHour'), viewOffset: this.getViewOffsetMs(), - tabIndex: this.option('tabIndex'), + tabIndex: undefined, accessKey: this.option('accessKey'), focusStateEnabled: this.option('focusStateEnabled'), cellDuration: this.option('cellDuration'),