From c79f4f88e43b22b857cc05cf13bd4eb18a2f00f0 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Mon, 18 May 2026 19:15:16 +0400 Subject: [PATCH 1/8] refactor(scheduler): appointments KBN From e8de742173b76b2d9a4603defa6874ccb04c4960 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Tue, 19 May 2026 17:43:00 +0400 Subject: [PATCH 2/8] feat(scheduler): add Delete key support for appointments_new KBN --- .../appointments.focus_controller.ts | 11 ++++ .../appointments_new/appointments.test.ts | 53 +++++++++++++++++++ .../appointments_new/appointments.ts | 7 +++ 3 files changed, 71 insertions(+) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index fafa90451a22..893850566e03 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -52,6 +52,8 @@ export class AppointmentsFocusController { public onViewItemKeyDown(viewItem: ViewItem, e: KeyboardKeyDownEvent): void { if (e.key === 'Tab') { this.handleTabKeyDown(e, viewItem.option().sortedIndex); + } else if (e.key === 'Delete') { + this.handleDeleteKeyDown(viewItem.option().sortedIndex); } } @@ -92,6 +94,15 @@ export class AppointmentsFocusController { this.focusByItemData(nextItemData); } + private handleDeleteKeyDown(sortedIndex: number): void { + const { allowDelete, onDeleteKeyPress } = this.appointments.option(); + if (!allowDelete) { return; } + + const itemData = this.sortedAppointments.find((s) => s.sortedIndex === sortedIndex)?.itemData; + if (!itemData) { return; } + onDeleteKeyPress({ data: itemData, target: null }); + } + private focusByItemData(itemData: SortedEntity): void { if (this.isVirtualScrolling) { this.scrollToItem(itemData); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index d7686404f264..f3bc2eb45d0b 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; import $ from '@js/core/renderer'; +import { fireEvent } from '@testing-library/dom'; import fx from '../../../common/core/animation/fx'; import { mockAppointmentDataAccessor } from '../__mock__/appointment_data_accessor.mock'; @@ -48,6 +49,10 @@ const getProperties = (options: { getAppointmentDataSource: mockAppointmentDataSource, getResourceManager: () => getResourceManagerMock(options.resources ?? []), getDataAccessor: () => mockAppointmentDataAccessor, + + allowDelete: false, + onDeleteKeyPress: (): void => {}, + onItemActivate: (): void => {}, }); const createAppointments = ( @@ -878,6 +883,54 @@ describe('Appointments', () => { expect(lastViewItem?.$element().attr('tabindex')).toBe('0'); }); }); + + describe('Keyboard actions', () => { + it('should call onDeleteKeyPress when Delete is pressed and allowDelete is true', () => { + const onDeleteKeyPress = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + allowDelete: true, + onDeleteKeyPress, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Delete' }); + + expect(onDeleteKeyPress).toHaveBeenCalledTimes(1); + expect(onDeleteKeyPress).toHaveBeenCalledWith( + expect.objectContaining({ data: defaultAppointmentData }), + ); + }); + + it('should not call onDeleteKeyPress when Delete is pressed and allowDelete is false', () => { + const onDeleteKeyPress = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + allowDelete: false, + onDeleteKeyPress, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Delete' }); + + expect(onDeleteKeyPress).not.toHaveBeenCalled(); + }); + }); }); describe('onAppointmentRendered', () => { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 263b4f4d9fc6..98d706d34f55 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -53,6 +53,10 @@ export interface AppointmentsProperties extends DOMComponentProperties SortedEntity[]; isVirtualScrolling: () => boolean; scrollTo: (date: Date, options?: ScrollToOptions) => void; + + allowDelete: boolean; + onDeleteKeyPress: (options: { data: SafeAppointment; target: EventTarget | null }) => void; + onItemActivate: (options: { data: SafeAppointment; target: EventTarget | null }) => void; } export class Appointments extends DOMComponent { @@ -109,6 +113,9 @@ export class Appointments extends DOMComponent {}, + allowDelete: false, + onDeleteKeyPress: (): void => {}, + onItemActivate: (): void => {}, }; } From 9b38cbf04189ed166bdbbd72376b75cb7923d69e Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Tue, 19 May 2026 23:59:00 +0400 Subject: [PATCH 3/8] feat(scheduler): add KBN home/end/enter/space + fix delete occurrence --- .../__tests__/appointments_new.test.ts | 39 +++++++ .../appointments.focus_controller.ts | 53 +++++++-- .../appointments_new/appointments.test.ts | 108 ++++++++++++++++-- .../appointments_new/appointments.ts | 3 +- .../js/__internal/scheduler/m_scheduler.ts | 6 + 5 files changed, 193 insertions(+), 16 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts index 426af8afe22f..a93f5015fcc1 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -4,6 +4,7 @@ import { import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import type { Properties } from '@js/ui/scheduler'; +import { fireEvent } from '@testing-library/dom'; import { createScheduler as baseCreateScheduler } from './__mock__/create_scheduler'; import { setupSchedulerTestEnvironment } from './__mock__/m_mock_scheduler'; @@ -375,4 +376,42 @@ describe('New Appointments', () => { expect(onAppointmentRendered).toHaveBeenCalledTimes(1); }); }); + + describe('Keyboard navigation', () => { + it('should delete appointment by delete key', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentDate: new Date(2015, 1, 9, 8), + }); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + + expect(POM.getAppointments().length).toBe(0); + }); + + it('should delete recurring appointment occurrence by delete key', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY;COUNT=3', + }], + currentDate: new Date(2015, 1, 9), + recurrenceEditMode: 'occurrence', + }); + + expect(POM.getAppointments().length).toBe(3); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + + expect(POM.getAppointments().length).toBe(2); + }); + }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 893850566e03..27f92840f82f 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -50,10 +50,27 @@ export class AppointmentsFocusController { } public onViewItemKeyDown(viewItem: ViewItem, e: KeyboardKeyDownEvent): void { - if (e.key === 'Tab') { - this.handleTabKeyDown(e, viewItem.option().sortedIndex); - } else if (e.key === 'Delete') { - this.handleDeleteKeyDown(viewItem.option().sortedIndex); + switch (true) { + case e.key === 'Tab': + this.handleTabKeyDown(e, viewItem.option().sortedIndex); + break; + case e.key === 'Delete': + this.handleDeleteKeyDown(viewItem.option().sortedIndex); + break; + case e.key === 'Home': + this.handleHomeKeyDown(); + break; + case e.key === 'End': + this.handleEndKeyDown(); + break; + case e.key === 'Enter': + this.handleEnterKeyDown(viewItem.option().sortedIndex); + break; + case e.key === ' ': + this.handleEnterKeyDown(viewItem.option().sortedIndex); + break; + default: + break; } } @@ -95,12 +112,32 @@ export class AppointmentsFocusController { } private handleDeleteKeyDown(sortedIndex: number): void { - const { allowDelete, onDeleteKeyPress } = this.appointments.option(); + const { allowDelete, onDeleteKeyPress, getDataAccessor } = this.appointments.option(); if (!allowDelete) { return; } - const itemData = this.sortedAppointments.find((s) => s.sortedIndex === sortedIndex)?.itemData; - if (!itemData) { return; } - onDeleteKeyPress({ data: itemData, target: null }); + const entity = this.sortedAppointments[sortedIndex]; + if (!entity) { return; } + + const occurrence = { ...entity.itemData }; + getDataAccessor().set('startDate', occurrence, new Date(entity.source.startDate)); + + onDeleteKeyPress({ appointment: entity.itemData, occurrence }); + } + + private handleHomeKeyDown(): void { + const firstAppointment = this.sortedAppointments[0]; + if (firstAppointment) { this.focusByItemData(firstAppointment); } + } + + private handleEndKeyDown(): void { + const lastAppointment = this.sortedAppointments[this.sortedAppointments.length - 1]; + if (lastAppointment) { this.focusByItemData(lastAppointment); } + } + + private handleEnterKeyDown(sortedIndex: number): void { + const { onItemActivate } = this.appointments.option(); + const entity = this.sortedAppointments[sortedIndex]; + onItemActivate({ data: entity?.itemData, target: null }); } private focusByItemData(itemData: SortedEntity): void { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index f3bc2eb45d0b..68b9e6982902 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -4,7 +4,6 @@ import { import $ from '@js/core/renderer'; import { fireEvent } from '@testing-library/dom'; -import fx from '../../../common/core/animation/fx'; import { mockAppointmentDataAccessor } from '../__mock__/appointment_data_accessor.mock'; import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; import type { ResourceConfig } from '../utils/loader/types'; @@ -72,8 +71,6 @@ const defaultAppointmentData = { describe('Appointments', () => { beforeEach(() => { - fx.off = true; - const $container = $('
') .addClass('container') .appendTo(document.body); @@ -89,8 +86,6 @@ describe('Appointments', () => { afterEach(() => { $('.container').remove(); - fx.off = false; - jest.useRealTimers(); }); describe('Classes', () => { @@ -884,6 +879,56 @@ describe('Appointments', () => { }); }); + describe('Home/End navigation', () => { + it('should move focus to first appointment on Home key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + const viewItem2 = instance.getViewItemBySortedIndex(2); + + (viewItem2?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem2?.$element().get(0) as HTMLElement, { key: 'Home' }); + + expect(viewItem0?.$element().attr('tabindex')).toBe('0'); + expect(viewItem2?.$element().attr('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(viewItem0?.$element().get(0)); + }); + + it('should move focus to last appointment on End key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + const viewItem2 = instance.getViewItemBySortedIndex(2); + + (viewItem0?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'End' }); + + expect(viewItem2?.$element().attr('tabindex')).toBe('0'); + expect(viewItem0?.$element().attr('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(viewItem2?.$element().get(0)); + }); + }); + describe('Keyboard actions', () => { it('should call onDeleteKeyPress when Delete is pressed and allowDelete is true', () => { const onDeleteKeyPress = jest.fn(); @@ -896,7 +941,11 @@ describe('Appointments', () => { ...getProperties(), allowDelete: true, onDeleteKeyPress, - getSortedAppointments: () => viewModel as unknown as SortedEntity[], + getSortedAppointments: () => [{ + sortedIndex: 0, + itemData: defaultAppointmentData, + source: { startDate: 0 }, + }] as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); @@ -906,7 +955,7 @@ describe('Appointments', () => { expect(onDeleteKeyPress).toHaveBeenCalledTimes(1); expect(onDeleteKeyPress).toHaveBeenCalledWith( - expect.objectContaining({ data: defaultAppointmentData }), + expect.objectContaining({ appointment: defaultAppointmentData }), ); }); @@ -930,6 +979,51 @@ describe('Appointments', () => { expect(onDeleteKeyPress).not.toHaveBeenCalled(); }); + + it('should call onItemActivate when Enter is pressed', () => { + const onItemActivate = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + onItemActivate, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: 'Enter' }); + + expect(onItemActivate).toHaveBeenCalledTimes(1); + expect(onItemActivate).toHaveBeenCalledWith( + expect.objectContaining({ data: defaultAppointmentData }), + ); + }); + it('should call onItemActivate when Space is pressed', () => { + const onItemActivate = jest.fn(); + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + onItemActivate, + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem = instance.getViewItemBySortedIndex(0); + (viewItem?.$element().get(0) as HTMLElement).click(); + fireEvent.keyDown(viewItem?.$element().get(0) as HTMLElement, { key: ' ' }); + + expect(onItemActivate).toHaveBeenCalledTimes(1); + expect(onItemActivate).toHaveBeenCalledWith( + expect.objectContaining({ data: defaultAppointmentData }), + ); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 98d706d34f55..c3b563bc0731 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -55,7 +55,8 @@ export interface AppointmentsProperties extends DOMComponentProperties void; allowDelete: boolean; - onDeleteKeyPress: (options: { data: SafeAppointment; target: EventTarget | null }) => void; + onDeleteKeyPress: (options: + { appointment: SafeAppointment; occurrence: SafeAppointment }) => void; onItemActivate: (options: { data: SafeAppointment; target: EventTarget | null }) => void; } diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 8289fa26eac5..cc41a1222822 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -1071,6 +1071,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { const appointmentsConfig: Partial = { tabIndex: this.option('tabIndex'), currentView: this.option('currentView') as ViewType, + allowDelete: this.editing.allowUpdating && this.editing.allowDeleting, appointmentTemplate: this.getViewOption('appointmentTemplate'), appointmentCollectorTemplate: this.getViewOption('appointmentCollectorTemplate'), onAppointmentRendered: (e) => { @@ -1081,6 +1082,11 @@ class Scheduler extends SchedulerOptionsBaseWidget { targetedAppointmentData: e.targetedAppointmentData, }); }, + onDeleteKeyPress: (e) => { + this.checkAndDeleteAppointment(e.appointment, e.occurrence); + }, + onItemActivate: ({ data }) => { this.showAppointmentPopup(data); }, + getResourceManager: () => this.resourceManager, getAppointmentDataSource: () => this.appointmentDataSource, getDataAccessor: () => this._dataAccessors, From 7c91113065a026c6f82e2cce76028b53b782da53 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 20 May 2026 12:26:29 +0400 Subject: [PATCH 4/8] refactor(scheduler): replace dispatchEvent with fireEvent in appointments tests --- .../appointments_new/appointments.test.ts | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index 68b9e6982902..4f2c0ffae2da 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -548,9 +548,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(1); (viewItem0?.$element().get(0) as HTMLElement).click(); - viewItem0?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(viewItem0?.$element().attr('tabindex')).toBe('-1'); expect(viewItem1?.$element().attr('tabindex')).toBe('0'); @@ -574,9 +572,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(1); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab', shiftKey: true }); expect(viewItem0?.$element().attr('tabindex')).toBe('0'); expect(viewItem1?.$element().attr('tabindex')).toBe('-1'); @@ -648,9 +644,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(0); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(scrollTo).toHaveBeenCalled(); }); @@ -673,9 +667,7 @@ describe('Appointments', () => { const viewItem2 = instance.getViewItemBySortedIndex(2); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(document.activeElement).toBe(viewItem2?.$element().get(0)); }); @@ -698,9 +690,7 @@ describe('Appointments', () => { const viewItem1 = instance.getViewItemBySortedIndex(1); (viewItem1?.$element().get(0) as HTMLElement).click(); - viewItem1?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem1?.$element().get(0) as HTMLElement, { key: 'Tab' }); // item2 is not rendered yet, so focus cannot move yet expect(instance.getViewItemBySortedIndex(2)).toBeUndefined(); @@ -734,9 +724,7 @@ describe('Appointments', () => { const viewItem0 = instance.getViewItemBySortedIndex(0); (viewItem0?.$element().get(0) as HTMLElement).click(); - viewItem0?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(scrollTo).toHaveBeenCalledWith(appointmentStartDate, expect.anything()); }); @@ -760,9 +748,7 @@ describe('Appointments', () => { const viewItem0 = instance.getViewItemBySortedIndex(0); (viewItem0?.$element().get(0) as HTMLElement).click(); - viewItem0?.$element().get(0).dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(viewItem0?.$element().get(0) as HTMLElement, { key: 'Tab' }); expect(scrollTo).toHaveBeenCalledWith(startViewDate, expect.anything()); }); @@ -771,9 +757,7 @@ describe('Appointments', () => { describe('Navigation after partial render', () => { const pressTab = (): void => { const activeElement = document.activeElement as HTMLElement; - activeElement.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), - ); + fireEvent.keyDown(activeElement, { key: 'Tab' }); }; it('should navigate to the last appointment correctly after an appointment is added', () => { From d1f3a12e667df4fdbe8ee40ff5f1b622ab325550 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Wed, 20 May 2026 12:53:45 +0400 Subject: [PATCH 5/8] fix(scheduler): use week view in recurring delete integration test --- .../js/__internal/scheduler/__tests__/appointments_new.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts index a93f5015fcc1..3de354f82985 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -402,6 +402,7 @@ describe('New Appointments', () => { recurrenceRule: 'FREQ=DAILY;COUNT=3', }], currentDate: new Date(2015, 1, 9), + currentView: 'week', recurrenceEditMode: 'occurrence', }); From 1c94a8eccb1629da1345c1d634531ddd822cd203 Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 21 May 2026 14:11:22 +0400 Subject: [PATCH 6/8] test(scheduler): add matrix tests for KBN delete key editing config --- .../__tests__/appointments_new.test.ts | 41 +++++++++++++++++++ .../appointments.focus_controller.ts | 4 +- .../appointments_new/appointments.ts | 4 +- .../js/__internal/scheduler/m_scheduler.ts | 8 +++- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts index 3de354f82985..65445cb0fbf8 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -414,5 +414,46 @@ describe('New Appointments', () => { expect(POM.getAppointments().length).toBe(2); }); + + it.each([ + { editing: true }, + { editing: { allowDeleting: true } }, + { editing: { allowDeleting: true, allowUpdating: false } }, + ])('should delete appointment when editing=$editing', async ({ editing }) => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentDate: new Date(2015, 1, 9, 8), + editing, + }); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + + expect(POM.getAppointments().length).toBe(0); + }); + + it.each([ + { editing: { allowDeleting: false } }, + { editing: false }, + ])('should NOT delete appointment when editing=$editing', async ({ editing }) => { + const { POM } = await createScheduler({ + dataSource: [{ + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + }], + currentDate: new Date(2015, 1, 9, 8), + editing, + }); + + const appointment = POM.getAppointments()[0]; + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + + expect(POM.getAppointments().length).toBe(1); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 27f92840f82f..202338201d7e 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -112,8 +112,8 @@ export class AppointmentsFocusController { } private handleDeleteKeyDown(sortedIndex: number): void { - const { allowDelete, onDeleteKeyPress, getDataAccessor } = this.appointments.option(); - if (!allowDelete) { return; } + const { editing, onDeleteKeyPress, getDataAccessor } = this.appointments.option(); + if (!editing.allowDeleting) { return; } const entity = this.sortedAppointments[sortedIndex]; if (!entity) { return; } diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index c3b563bc0731..3d99849b1ef6 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -54,7 +54,7 @@ export interface AppointmentsProperties extends DOMComponentProperties boolean; scrollTo: (date: Date, options?: ScrollToOptions) => void; - allowDelete: boolean; + editing: { allowDeleting: boolean }; onDeleteKeyPress: (options: { appointment: SafeAppointment; occurrence: SafeAppointment }) => void; onItemActivate: (options: { data: SafeAppointment; target: EventTarget | null }) => void; @@ -114,7 +114,7 @@ export class Appointments extends DOMComponent {}, - allowDelete: false, + editing: { allowDeleting: false }, onDeleteKeyPress: (): void => {}, onItemActivate: (): void => {}, }; diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index cc41a1222822..1dbf21ff1016 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -503,7 +503,11 @@ class Scheduler extends SchedulerOptionsBaseWidget { this.initEditing(); const { editing } = this; - this.bringEditingModeToAppointments(editing); + if (this.option('_newAppointments')) { + this._appointments.option('editing', this.editing); + } else { + this.bringEditingModeToAppointments(editing); + } this.hideAppointmentTooltip(); this.createAppointmentPopupForm(); @@ -1071,7 +1075,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { const appointmentsConfig: Partial = { tabIndex: this.option('tabIndex'), currentView: this.option('currentView') as ViewType, - allowDelete: this.editing.allowUpdating && this.editing.allowDeleting, + editing: this.editing, appointmentTemplate: this.getViewOption('appointmentTemplate'), appointmentCollectorTemplate: this.getViewOption('appointmentCollectorTemplate'), onAppointmentRendered: (e) => { From 9b0f76178f55c2fdae31e3995f17d2548f2e39dc Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 21 May 2026 14:43:40 +0400 Subject: [PATCH 7/8] refactor(scheduler): rename entity to sortedItem in KBN focus controller --- .../appointments.focus_controller.ts | 15 ++++++++------- .../appointments_new/appointments.test.ts | 7 +++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 202338201d7e..46ab3103dd9d 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -115,13 +115,13 @@ export class AppointmentsFocusController { const { editing, onDeleteKeyPress, getDataAccessor } = this.appointments.option(); if (!editing.allowDeleting) { return; } - const entity = this.sortedAppointments[sortedIndex]; - if (!entity) { return; } + const sortedItem = this.sortedAppointments[sortedIndex]; + if (!sortedItem) { return; } - const occurrence = { ...entity.itemData }; - getDataAccessor().set('startDate', occurrence, new Date(entity.source.startDate)); + const occurrence = { ...sortedItem.itemData }; + getDataAccessor().set('startDate', occurrence, new Date(sortedItem.source.startDate)); - onDeleteKeyPress({ appointment: entity.itemData, occurrence }); + onDeleteKeyPress({ appointment: sortedItem.itemData, occurrence }); } private handleHomeKeyDown(): void { @@ -136,8 +136,9 @@ export class AppointmentsFocusController { private handleEnterKeyDown(sortedIndex: number): void { const { onItemActivate } = this.appointments.option(); - const entity = this.sortedAppointments[sortedIndex]; - onItemActivate({ data: entity?.itemData, target: null }); + const sortedItem = this.sortedAppointments[sortedIndex]; + if (!sortedItem) { return; } + onItemActivate({ data: sortedItem.itemData, target: null }); } private focusByItemData(itemData: SortedEntity): void { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index 4f2c0ffae2da..f513e1f73ee7 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -49,7 +49,7 @@ const getProperties = (options: { getResourceManager: () => getResourceManagerMock(options.resources ?? []), getDataAccessor: () => mockAppointmentDataAccessor, - allowDelete: false, + editing: { allowDeleting: false }, onDeleteKeyPress: (): void => {}, onItemActivate: (): void => {}, }); @@ -923,7 +923,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - allowDelete: true, + editing: { allowDeleting: true }, onDeleteKeyPress, getSortedAppointments: () => [{ sortedIndex: 0, @@ -951,8 +951,7 @@ describe('Appointments', () => { const instance = createAppointments({ ...getProperties(), - allowDelete: false, - onDeleteKeyPress, + editing: { allowDeleting: false }, getSortedAppointments: () => viewModel as unknown as SortedEntity[], }); instance.option('viewModel', viewModel); From c517882edcb18bbbec1e29d72e60ab1fc9a3eb7e Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Thu, 21 May 2026 15:58:28 +0400 Subject: [PATCH 8/8] fix(scheduler): add collector guard and preventDefault for KBN keys --- .../appointments.focus_controller.ts | 29 ++++++++----- .../appointments_new/appointments.test.ts | 41 +++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts index 46ab3103dd9d..5ba830187abb 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -5,6 +5,7 @@ import { focus } from '@ts/events/m_short'; import { getRawAppointmentGroupValues } from '../utils/resource_manager/appointment_groups_utils'; import type { SortedEntity } from '../view_model/types'; +import { AppointmentCollector } from './appointment_collector'; import type { Appointments } from './appointments'; import type { ViewItem } from './view_item'; @@ -14,15 +15,15 @@ export class AppointmentsFocusController { private needRestoreFocusIndex = -1; private get sortedAppointments(): SortedEntity[] { - return this.appointments.option().getSortedAppointments(); + return (this.appointments.option()).getSortedAppointments(); } private get isVirtualScrolling(): boolean { - return this.appointments.option().isVirtualScrolling(); + return (this.appointments.option()).isVirtualScrolling(); } private get tabIndex(): number | undefined { - return this.appointments.option().tabIndex; + return (this.appointments.option()).tabIndex; } constructor(private readonly appointments: Appointments) { } @@ -55,13 +56,14 @@ export class AppointmentsFocusController { this.handleTabKeyDown(e, viewItem.option().sortedIndex); break; case e.key === 'Delete': - this.handleDeleteKeyDown(viewItem.option().sortedIndex); + if (viewItem instanceof AppointmentCollector) { break; } + this.handleDeleteKeyDown(viewItem.option().sortedIndex, e); break; case e.key === 'Home': - this.handleHomeKeyDown(); + this.handleHomeKeyDown(e); break; case e.key === 'End': - this.handleEndKeyDown(); + this.handleEndKeyDown(e); break; case e.key === 'Enter': this.handleEnterKeyDown(viewItem.option().sortedIndex); @@ -111,27 +113,32 @@ export class AppointmentsFocusController { this.focusByItemData(nextItemData); } - private handleDeleteKeyDown(sortedIndex: number): void { + private handleDeleteKeyDown(sortedIndex: number, e: KeyboardKeyDownEvent): void { const { editing, onDeleteKeyPress, getDataAccessor } = this.appointments.option(); if (!editing.allowDeleting) { return; } const sortedItem = this.sortedAppointments[sortedIndex]; if (!sortedItem) { return; } + e.originalEvent.preventDefault(); const occurrence = { ...sortedItem.itemData }; getDataAccessor().set('startDate', occurrence, new Date(sortedItem.source.startDate)); onDeleteKeyPress({ appointment: sortedItem.itemData, occurrence }); } - private handleHomeKeyDown(): void { + private handleHomeKeyDown(e: KeyboardKeyDownEvent): void { const firstAppointment = this.sortedAppointments[0]; - if (firstAppointment) { this.focusByItemData(firstAppointment); } + if (!firstAppointment) { return; } + e.originalEvent.preventDefault(); + this.focusByItemData(firstAppointment); } - private handleEndKeyDown(): void { + private handleEndKeyDown(e: KeyboardKeyDownEvent): void { const lastAppointment = this.sortedAppointments[this.sortedAppointments.length - 1]; - if (lastAppointment) { this.focusByItemData(lastAppointment); } + if (!lastAppointment) { return; } + e.originalEvent.preventDefault(); + this.focusByItemData(lastAppointment); } private handleEnterKeyDown(sortedIndex: number): void { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index f513e1f73ee7..99bd66a52b45 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -911,6 +911,47 @@ describe('Appointments', () => { expect(viewItem0?.$element().attr('tabindex')).toBe('-1'); expect(document.activeElement).toBe(viewItem2?.$element().get(0)); }); + it('should call preventDefault on Home key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem1 = instance.getViewItemBySortedIndex(1); + (viewItem1?.$element().get(0) as HTMLElement).click(); + + const event = new KeyboardEvent('keydown', { key: 'Home', bubbles: true, cancelable: true }); + viewItem1?.$element().get(0)?.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); + + it('should call preventDefault on End key', () => { + const viewModel = [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + (viewItem0?.$element().get(0) as HTMLElement).click(); + + const event = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }); + viewItem0?.$element().get(0)?.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + }); }); describe('Keyboard actions', () => {