From c10c3893e59211013505a8e7d5f8715601709013 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Fri, 13 Feb 2026 16:20:12 +0800 Subject: [PATCH 1/2] Implement Home and End, remove tabindex from workspace and header, change role of scheduler --- .../__tests__/__mock__/model/appointment.ts | 2 + .../__tests__/__mock__/model/scheduler.ts | 8 ++ .../scheduler/__tests__/appointments.test.ts | 98 +++++++++++++++++ .../scheduler/__tests__/header.test.ts | 67 ++++++++++++ .../scheduler/__tests__/workspace.test.ts | 100 ++++++++++++++++++ .../appointments/m_appointments_kbn.ts | 30 +++++- .../js/__internal/scheduler/m_scheduler.ts | 10 +- 7 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/header.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts 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 c65cee6d9b4c..ffa8a90fc09e 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/__mock__/model/scheduler.ts @@ -25,10 +25,18 @@ export class SchedulerModel { return new ToolbarModel(this.container.querySelector('.dx-scheduler-header')); } + getHeader(): HTMLElement { + return this.container.querySelector('.dx-scheduler-header') as HTMLElement; + } + getStatusContent(): string { return this.container.querySelector('.dx-screen-reader-only')?.textContent ?? ''; } + getWorkSpace(): HTMLElement { + return this.container.querySelector('.dx-scheduler-work-space') as HTMLElement; + } + getAppointment(text?: string): AppointmentModel { if (!text) { return createAppointmentModel(this.container.querySelector('.dx-scheduler-appointment')); 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__/workspace.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts new file mode 100644 index 000000000000..483bf5880c3e --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/__tests__/workspace.test.ts @@ -0,0 +1,100 @@ +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__/m_mock_scheduler'; + +const CLASSES = { + scheduler: 'dx-scheduler', +}; + +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, POM } = 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 = $(POM.getWorkSpace()); + const $groupHeader = $(container).find('.dx-scheduler-group-header'); + + expect($workSpaces.length).toBe(1); + + 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'), From 2c071d7aba19e879417548bdcd1d7b5c3837dae1 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Fri, 13 Feb 2026 17:19:25 +0800 Subject: [PATCH 2/2] fix a11y test --- .../accessibility/scheduler/scheduler.ts | 2 +- .../__tests__/toolbar_adaptivity.test.ts | 24 ------ .../__tests__/workspace.recalculation.test.ts | 79 ------------------- 3 files changed, 1 insertion(+), 104 deletions(-) delete mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/toolbar_adaptivity.test.ts delete mode 100644 packages/devextreme/js/__internal/scheduler/__tests__/workspace.recalculation.test.ts 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__/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.recalculation.test.ts deleted file mode 100644 index 13ffa2259716..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__/m_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'); - }); -});