From c7c14a56ce89a9e59dad5dfd29155ffcb4015b7d Mon Sep 17 00:00:00 2001 From: Maksim Zakharov <251575087+bit-byte0@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:35:47 +0400 Subject: [PATCH] fix(scheduler): restore focus to nearest appointment after keyboard delete --- .../common/keyboardNavigation/appointments.ts | 87 +++++++++++++ .../scheduler/__tests__/appointments.test.ts | 115 +++++++++++++++++ .../__tests__/appointments_new.test.ts | 116 ++++++++++++++++++ .../appointments/m_appointment_collection.ts | 1 + .../appointments/m_appointments_kbn.ts | 51 +++++++- .../appointments.focus_controller.ts | 47 +++++++ .../appointments_new/appointments.test.ts | 1 + .../appointments_new/appointments.ts | 6 +- .../js/__internal/scheduler/m_subscribes.ts | 5 + .../js/__internal/scheduler/scheduler.ts | 19 +++ .../scheduler/utils/is_focus_lost.ts | 10 ++ 11 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/utils/is_focus_lost.ts diff --git a/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts index e21abaa877b1..7376fe726d55 100644 --- a/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts +++ b/e2e/testcafe-devextreme/tests/scheduler/common/keyboardNavigation/appointments.ts @@ -1,4 +1,5 @@ import Scheduler from 'devextreme-testcafe-models/scheduler'; +import { Selector } from 'testcafe'; import url from '../../../../helpers/getPageUrl'; import { createWidget } from '../../../../helpers/createWidget'; import { generateAppointmentsWithResources, resources } from '../../helpers/generateAppointmentsWithResources'; @@ -276,3 +277,89 @@ test('should focus first rendered appointment on tab (standard scrolling)', asyn await insertStylesheetRulesToPage(cellStyles); await createWidget('dxScheduler', { ...getConfig(), scrolling: { mode: 'standard' } }); }); + +const getDeleteFocusConfig = () => ({ + dataSource: [ + { text: 'Appointment 1', startDate: new Date(2021, 1, 2, 9), endDate: new Date(2021, 1, 2, 10) }, + { text: 'Appointment 2', startDate: new Date(2021, 1, 2, 10), endDate: new Date(2021, 1, 2, 11) }, + { text: 'Appointment 3', startDate: new Date(2021, 1, 2, 11), endDate: new Date(2021, 1, 2, 12) }, + ], + views: ['day'], + currentView: 'day', + currentDate: new Date(2021, 1, 2), + startDayHour: 8, + endDayHour: 20, + height: 600, +}); + +test('should focus next appointment after deleting appointment by Delete key', async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('Appointment 2').element) + .pressKey('delete'); + + await t + .expect(scheduler.getAppointmentCount()).eql(2) + .expect(scheduler.getAppointment('Appointment 3').isFocused).ok(); +}).before(async () => { + await createWidget('dxScheduler', getDeleteFocusConfig()); +}); + +test('should focus previous appointment after deleting the last appointment by Delete key', async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('Appointment 3').element) + .pressKey('delete'); + + await t + .expect(scheduler.getAppointmentCount()).eql(2) + .expect(scheduler.getAppointment('Appointment 2').isFocused).ok(); +}).before(async () => { + await createWidget('dxScheduler', getDeleteFocusConfig()); +}); + +test('should focus toolbar element when no appointments remain after deleting by Delete key', async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('Appointment 1').element) + .pressKey('delete'); + + await t + .expect(scheduler.getAppointmentCount()).eql(0) + .expect(scheduler.toolbar.element.find(':focus').exists).ok(); +}).before(async () => { + await createWidget('dxScheduler', { + ...getDeleteFocusConfig(), + dataSource: [ + { text: 'Appointment 1', startDate: new Date(2021, 1, 2, 9), endDate: new Date(2021, 1, 2, 10) }, + ], + }); +}); + +test('should focus next occurrence after deleting recurring occurrence via dialog', async (t) => { + const scheduler = new Scheduler(SCHEDULER_SELECTOR); + + await t + .click(scheduler.getAppointment('Recurring Appointment', 2).element) + .pressKey('delete') + .click(Selector('.dx-dialog-button').withText('Delete appointment')); + + await t + .expect(scheduler.getAppointmentCount()).eql(4) + .expect(scheduler.getAppointment('Recurring Appointment', 2).isFocused).ok(); +}).before(async () => { + await createWidget('dxScheduler', { + ...getDeleteFocusConfig(), + views: ['week'], + currentView: 'week', + dataSource: [{ + text: 'Recurring Appointment', + startDate: new Date(2021, 1, 1, 9), + endDate: new Date(2021, 1, 1, 10), + recurrenceRule: 'FREQ=DAILY;COUNT=5', + }], + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts b/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts index e12560d2e30a..64dc4b72c2be 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments.test.ts @@ -325,5 +325,120 @@ describe('Appointments', () => { expect(POM.getAppointments().length).toBe(initialCount - 1); }); }); + + describe('Focus after Delete', () => { + it('should focus next appointment after deleting via Delete key', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [...dataSource], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointment = POM.getAppointment('Appointment 2'); + + appointment.element?.focus(); + keydown(appointment.element as Element, 'Delete'); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(2); + expect(POM.getAppointment('Appointment 3').isFocused()).toBe(true); + }); + + it('should focus previous appointment after deleting the last one', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [...dataSource], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointment = POM.getAppointment('Appointment 3'); + + appointment.element?.focus(); + keydown(appointment.element as Element, 'Delete'); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(2); + expect(POM.getAppointment('Appointment 2').isFocused()).toBe(true); + }); + + it('should focus toolbar element when no appointments remain after delete', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [dataSource[0]], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointment = POM.getAppointment('Appointment 1'); + + appointment.element?.focus(); + keydown(appointment.element as Element, 'Delete'); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(0); + expect(POM.toolbar.element.contains(document.activeElement)).toBe(true); + }); + + it('should focus workspace when no appointments remain after delete and toolbar is hidden', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [dataSource[0]], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + toolbar: { items: [] }, + }); + + const appointment = POM.getAppointment('Appointment 1'); + + appointment.element?.focus(); + keydown(appointment.element as Element, 'Delete'); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(0); + expect(document.activeElement).toBe(POM.getWorkspace()); + }); + + it('should keep focus on appointment when deleting is canceled', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [...dataSource], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + onAppointmentDeleting: (e) => { + e.cancel = true; + }, + }); + + const appointment = POM.getAppointment('Appointment 2'); + + appointment.element?.focus(); + keydown(appointment.element as Element, 'Delete'); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(3); + expect(document.activeElement).toBe(appointment.element); + }); + + it('should focus next occurrence after deleting recurring occurrence via dialog', async () => { + const { POM, keydown } = await createScheduler({ + dataSource: [{ + text: 'Recurring Appointment', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY', + }], + currentView: 'week', + currentDate: new Date(2015, 1, 9), + }); + + const initialCount = POM.getAppointments().length; + const appointment = POM.getAppointments()[2]; + + appointment.element.focus(); + keydown(appointment.element, 'Delete'); + POM.popup.deleteAppointmentButton.click(); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(initialCount - 1); + expect(POM.getAppointments()[2].isFocused()).toBe(true); + }); + }); }); }); 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 d630adb5d5c7..c63fa0f8dad4 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -930,5 +930,121 @@ describe('New Appointments', () => { expect(POM.getAppointments().length).toBe(1); }); + + describe('Focus after delete', () => { + 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 next appointment after deleting via Delete key', async () => { + const { POM } = await createScheduler({ + dataSource: [...dataSource], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointment = POM.getAppointment('Appointment 2'); + appointment.element?.focus(); + fireEvent.keyDown(appointment.element as Element, { key: 'Delete' }); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(2); + expect(document.activeElement).toBe(POM.getAppointment('Appointment 3').element); + }); + + it('should focus previous appointment after deleting the last one', async () => { + const { POM } = await createScheduler({ + dataSource: [...dataSource], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointment = POM.getAppointment('Appointment 3'); + appointment.element?.focus(); + fireEvent.keyDown(appointment.element as Element, { key: 'Delete' }); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(2); + expect(document.activeElement).toBe(POM.getAppointment('Appointment 2').element); + }); + + it('should focus toolbar element when no appointments remain after delete', async () => { + const { POM } = await createScheduler({ + dataSource: [dataSource[0]], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + }); + + const appointment = POM.getAppointment('Appointment 1'); + appointment.element?.focus(); + fireEvent.keyDown(appointment.element as Element, { key: 'Delete' }); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(0); + expect(POM.toolbar.element.contains(document.activeElement)).toBe(true); + }); + + it('should focus workspace when no appointments remain after delete and toolbar is hidden', async () => { + const { POM } = await createScheduler({ + dataSource: [dataSource[0]], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + toolbar: { items: [] }, + }); + + const appointment = POM.getAppointment('Appointment 1'); + appointment.element?.focus(); + fireEvent.keyDown(appointment.element as Element, { key: 'Delete' }); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(0); + expect(document.activeElement).toBe(POM.getWorkspace()); + }); + + it('should keep focus on appointment when deleting is canceled', async () => { + const { POM } = await createScheduler({ + dataSource: [...dataSource], + currentView: 'day', + currentDate: new Date(2015, 1, 9), + onAppointmentDeleting: (e) => { + e.cancel = true; + }, + }); + + const appointment = POM.getAppointment('Appointment 2'); + appointment.element?.focus(); + fireEvent.keyDown(appointment.element as Element, { key: 'Delete' }); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(3); + expect(document.activeElement).toBe(appointment.element); + }); + + it('should focus next occurrence after deleting recurring occurrence via dialog', async () => { + const { POM } = await createScheduler({ + dataSource: [{ + text: 'Recurring Appointment', + startDate: new Date(2015, 1, 9, 8), + endDate: new Date(2015, 1, 9, 9), + recurrenceRule: 'FREQ=DAILY', + }], + currentView: 'week', + currentDate: new Date(2015, 1, 9), + }); + + const initialCount = POM.getAppointments().length; + const appointment = POM.getAppointments()[2]; + + appointment.element.focus(); + fireEvent.keyDown(appointment.element, { key: 'Delete' }); + POM.popup.deleteAppointmentButton.click(); + await new Promise(process.nextTick); + + expect(POM.getAppointments().length).toBe(initialCount - 1); + expect(document.activeElement).toBe(POM.getAppointments()[2].element); + }); + }); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts index 224bb2867e16..25885bd5de2e 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointment_collection.ts @@ -291,6 +291,7 @@ class SchedulerAppointments extends CollectionWidget { } this._attachAppointmentsEvents(); + this._kbn.onItemsRendered(); break; case 'fixedContainer': case 'allDayContainer': 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 690913835b5c..e089bb918b40 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments/m_appointments_kbn.ts @@ -1,10 +1,12 @@ import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import { noop } from '@js/core/utils/common'; import type { DxEvent } from '@js/events/events.types'; import { getPublicElement } from '@ts/core/m_element'; import type { SupportedKeys } from '@ts/core/widget/widget'; import eventsEngine from '@ts/events/core/m_events_engine'; +import { isFocusLost } from '../utils/is_focus_lost'; import { getRawAppointmentGroupValues } from '../utils/resource_manager/appointment_groups_utils'; import type { SortedEntity } from '../view_model/types'; import type SchedulerAppointments from './m_appointment_collection'; @@ -16,6 +18,8 @@ export class AppointmentsKeyboardNavigation { public isNavigating = false; + private pendingDeleteFocusSortedIndex = -1; + constructor(collection: SchedulerAppointments) { this._collection = collection; } @@ -83,9 +87,52 @@ export class AppointmentsKeyboardNavigation { private delHandler(e: DxEvent): void { if (this._collection.option('allowDelete')) { e.preventDefault(); - const data = this._collection.getAppointmentSettings($(e.target)).itemData; - this._collection.notifyObserver('onDeleteButtonPress', { data, target: e.target }); + const settings = this._collection.getAppointmentSettings($(e.target)); + this.pendingDeleteFocusSortedIndex = settings.sortedIndex; + this._collection.notifyObserver('onDeleteButtonPress', { data: settings.itemData, target: e.target }); + } + } + + public onItemsRendered(): void { + const pendingIndex = this.pendingDeleteFocusSortedIndex; + this.pendingDeleteFocusSortedIndex = -1; + + if (pendingIndex < 0) { + return; } + + Promise.resolve().then(() => { + this.restoreFocusAfterDelete(pendingIndex); + }).catch(noop); + } + + private restoreFocusAfterDelete(sortedIndex: number): void { + if (!isFocusLost()) { + return; + } + + const $item = this.findNearestItem(sortedIndex); + + if ($item) { + this.focus($item); + return; + } + + this._collection.notifyObserver('focusFallbackAfterDelete', {}); + } + + private findNearestItem(sortedIndex: number): dxElementWrapper | undefined { + const $items = this._collection.$itemBySortedIndex; + + for (let index = sortedIndex; index >= 0; index -= 1) { + const $item = $items?.[index]; + + if ($item?.length) { + return $item; + } + } + + return undefined; } private escHandler(): void { 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 4a974c474540..f297eff03ca4 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 @@ -1,8 +1,10 @@ import $ from '@js/core/renderer'; +import { noop } from '@js/core/utils/common'; import type { DxEvent } from '@js/events'; import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; import { focus } from '@ts/events/m_short'; +import { isFocusLost } from '../utils/is_focus_lost'; import { getRawAppointmentGroupValues } from '../utils/resource_manager/appointment_groups_utils'; import type { SortedEntity } from '../view_model/types'; import type { BaseAppointmentView } from './appointment/base_appointment'; @@ -19,6 +21,8 @@ export class AppointmentsFocusController { private needRestoreFocusIndex = -1; + private pendingDeleteFocusSortedIndex = -1; + private get sortedItems(): SortedEntity[] { return this.appointments.option().getSortedItems(); } @@ -83,6 +87,48 @@ export class AppointmentsFocusController { } } + public onViewItemsRendered(): void { + const pendingIndex = this.pendingDeleteFocusSortedIndex; + this.pendingDeleteFocusSortedIndex = -1; + + this.resetTabIndex(); + + if (pendingIndex < 0) { + return; + } + + Promise.resolve().then(() => { + this.restoreFocusAfterDelete(pendingIndex); + }).catch(noop); + } + + private restoreFocusAfterDelete(sortedIndex: number): void { + if (!isFocusLost()) { + return; + } + + const viewItem = this.findNearestViewItem(sortedIndex); + + if (viewItem) { + this.focusViewItem(viewItem); + return; + } + + this.appointments.option().focusFallbackAfterDelete(); + } + + private findNearestViewItem(sortedIndex: number): ViewItem | undefined { + for (let index = sortedIndex; index >= 0; index -= 1) { + const viewItem = this.appointments.getViewItemBySortedIndex(index); + + if (viewItem) { + return viewItem; + } + } + + return undefined; + } + public resetTabIndex(newFocusableIndex?: number): void { if (this.needRestoreFocusIndex >= 0) { const viewItem = this.appointments.getViewItemBySortedIndex( @@ -127,6 +173,7 @@ export class AppointmentsFocusController { if (!allowDelete) { return; } const appointmentViewItem = viewItem as BaseAppointmentView; + this.pendingDeleteFocusSortedIndex = appointmentViewItem.option().sortedIndex; onDeleteKeyPress({ appointmentData: appointmentViewItem.appointmentData, targetedAppointmentData: appointmentViewItem.targetedAppointmentData, 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 ac8aa5a97391..7e68750471e3 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -56,6 +56,7 @@ const getProperties = (options: { showEditAppointmentPopup: (): void => {}, allowDelete: false, onDeleteKeyPress: (): void => {}, + focusFallbackAfterDelete: (): void => {}, }); const createAppointments = ( diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 4a8a1f1c233e..8a22cbfc4e5d 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -85,6 +85,7 @@ export interface AppointmentsProperties extends DOMComponentProperties void; + focusFallbackAfterDelete: () => void; } export class Appointments extends DOMComponent { @@ -192,6 +193,7 @@ export class Appointments extends DOMComponent: delete this method when old impl is removed + focusFallbackAfterDelete() { + this.focusFallbackAfterDelete(); + }, + // TODO: delete this method when old impl is removed createFormattedDateText( appointment: AppointmentTooltipItem['appointment'], diff --git a/packages/devextreme/js/__internal/scheduler/scheduler.ts b/packages/devextreme/js/__internal/scheduler/scheduler.ts index 5b9b4280f52d..d891c6bdc1f1 100644 --- a/packages/devextreme/js/__internal/scheduler/scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/scheduler.ts @@ -58,7 +58,9 @@ import type { import errors from '@js/ui/widget/ui.errors'; import type { Options } from '@ts/core/options/m_index'; import { dateUtilsTs } from '@ts/core/utils/date'; +import { tabbable } from '@ts/core/utils/m_selectors'; import type { OptionChanged } from '@ts/core/widget/types'; +import { focus } from '@ts/events/m_short'; import type Scrollable from '@ts/ui/scroll_view/scrollable'; import { createA11yStatusContainer } from './a11y_status/a11y_status_render'; @@ -1356,6 +1358,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { onDeleteKeyPress: (e) => { this.checkAndDeleteAppointment(e.appointmentData, e.targetedAppointmentData); }, + focusFallbackAfterDelete: () => this.focusFallbackAfterDelete(), getResourceManager: () => this.resourceManager, getAppointmentDataSource: () => this.appointmentDataSource, @@ -1566,6 +1569,22 @@ class Scheduler extends SchedulerOptionsBaseWidget { }) as AppointmentTooltipContextMenuEventArgs; } + focusFallbackAfterDelete(): void { + const $headerFocusable = this.header?.$element() + .find('*') + .not('.dx-state-invisible, .dx-state-invisible *') + // @ts-expect-error filter callback overload should be added on dxElementWrapper level + .filter(tabbable) + .first(); + + if ($headerFocusable?.length) { + focus.trigger($headerFocusable); + return; + } + + this._workSpace?.focus(); + } + checkAndDeleteAppointment( appointment: SafeAppointment, targetedAppointment?: SafeAppointment | TargetedAppointment, diff --git a/packages/devextreme/js/__internal/scheduler/utils/is_focus_lost.ts b/packages/devextreme/js/__internal/scheduler/utils/is_focus_lost.ts new file mode 100644 index 000000000000..9c8e6ba7101f --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/utils/is_focus_lost.ts @@ -0,0 +1,10 @@ +import { domAdapter } from '@ts/core/m_dom_adapter'; + +export const isFocusLost = (): boolean => { + const activeElement = domAdapter.getActiveElement(); + const body = domAdapter.getBody(); + + return !activeElement + || activeElement === body + || !body.contains(activeElement); +};