From 6148af57f4fa8fa054de391ebacc89e70f5e96ea Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Sat, 11 Apr 2026 14:15:39 +0800 Subject: [PATCH 1/5] implement focus controller --- .../__tests__/appointments_new.test.ts | 12 ++ .../__mock__/appointment_properties.ts | 8 +- .../appointment/agenda_appointment.test.ts | 4 +- .../appointment/agenda_appointment.ts | 17 ++- .../appointment/base_appointment.test.ts | 10 +- .../appointment/base_appointment.ts | 100 +++++++++++++++- .../appointment/grid_appointment.test.ts | 4 +- .../appointment/grid_appointment.ts | 15 ++- .../appointment_collector.test.ts | 8 +- .../appointments_new/appointment_collector.ts | 87 ++++++++++++-- .../appointments.focus_controller.ts | 112 ++++++++++++++++++ .../appointments_new/appointments.ts | 108 ++++++++++++----- .../scheduler/appointments_new/const.ts | 3 + .../scheduler/appointments_new/types.ts | 13 ++ .../js/__internal/scheduler/m_scheduler.ts | 10 +- 15 files changed, 441 insertions(+), 70 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/types.ts 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 3f56d8ea273f..eb6bf531eed7 100644 --- a/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts +++ b/packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts @@ -66,6 +66,18 @@ describe('New Appointments', () => { return $(POM.getAppointments()[0].element).text() === 'Custom appointment'; }; + it('should render default template', async () => { + const { POM } = await createScheduler(config); + + if (templateName === 'appointmentCollectorTemplate') { + const collectorButton = POM.getCollectorButton(); + expect(collectorButton.textContent).toBe('1'); + } else { + const appointment = POM.getAppointments()[0]; + expect(appointment.getText()).toBe('Appointment 1'); + } + }); + it('should apply custom template', async () => { const { POM } = await createScheduler({ ...config, diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts index 1a10a4fc3cd9..de5896af4b67 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts @@ -5,7 +5,7 @@ import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/ import type { BaseAppointmentViewProperties } from '../appointment/base_appointment'; -export const getBaseAppointmentProperties = ( +export const getBaseAppointmentViewProperties = ( appointmentData: SafeAppointment, targetedAppointmentData?: TargetedAppointment, ): BaseAppointmentViewProperties => { @@ -17,10 +17,14 @@ export const getBaseAppointmentProperties = ( const config: BaseAppointmentViewProperties = { index: 0, + tabIndex: 0, appointmentData, targetedAppointmentData: normalizedTargetedAppointmentData, appointmentTemplate: new EmptyTemplate(), - onAppointmentRendered: () => {}, + onRendered: () => {}, + onFocusIn: () => {}, + onFocusOut: () => {}, + onKeyDown: () => {}, getDataAccessor: (): AppointmentDataAccessor => mockAppointmentDataAccessor, getResourceColor: (): Promise => Promise.resolve(undefined), }; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.test.ts index da1a3b867064..47d562fbcd6c 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.test.ts @@ -6,7 +6,7 @@ import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; import type { AppointmentResource } from '@ts/scheduler/utils/resource_manager/appointment_groups_utils'; import fx from '../../../common/core/animation/fx'; -import { getBaseAppointmentProperties } from '../__mock__/appointment_properties'; +import { getBaseAppointmentViewProperties } from '../__mock__/appointment_properties'; import { AGENDA_APPOINTMENT_CLASSES, APPOINTMENT_CLASSES } from '../const'; import type { AgendaAppointmentViewProperties } from './agenda_appointment'; import { AgendaAppointmentView } from './agenda_appointment'; @@ -15,7 +15,7 @@ const getProperties = ( appointmentData: SafeAppointment, targetedAppointmentData?: TargetedAppointment, ): AgendaAppointmentViewProperties => { - const baseProperties = getBaseAppointmentProperties( + const baseProperties = getBaseAppointmentViewProperties( appointmentData, targetedAppointmentData, ); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.ts index ad4b8985b0ea..bf85202b6577 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/agenda_appointment.ts @@ -4,7 +4,11 @@ import type { SafeAppointment } from '@ts/scheduler/types'; import type { AppointmentResource } from '@ts/scheduler/utils/resource_manager/appointment_groups_utils'; import { - AGENDA_APPOINTMENT_CLASSES, ALL_DAY_TEXT, APPOINTMENT_CLASSES, RECURRING_LABEL, + AGENDA_APPOINTMENT_CLASSES, + ALL_DAY_TEXT, + APPOINTMENT_CLASSES, + APPOINTMENT_TYPE_CLASSES, + RECURRING_LABEL, } from '../const'; import type { BaseAppointmentViewProperties } from './base_appointment'; import { BaseAppointmentView } from './base_appointment'; @@ -32,11 +36,11 @@ export class AgendaAppointmentView extends BaseAppointmentView { if (color) { + this.$element().addClass(APPOINTMENT_TYPE_CLASSES.HAS_RESOURCE); $marker.css('backgroundColor', color); } }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts index de9ea8198a70..b5b102a19cd1 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts @@ -4,7 +4,7 @@ import { import $ from '@js/core/renderer'; import fx from '../../../common/core/animation/fx'; -import { getBaseAppointmentProperties } from '../__mock__/appointment_properties'; +import { getBaseAppointmentViewProperties } from '../__mock__/appointment_properties'; import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const'; import type { BaseAppointmentViewProperties } from './base_appointment'; import { BaseAppointmentView } from './base_appointment'; @@ -51,7 +51,7 @@ describe('BaseAppointment', () => { describe('Classes', () => { it('should have container class', async () => { const instance = await createBaseAppointment( - getBaseAppointmentProperties(defaultAppointmentData), + getBaseAppointmentViewProperties(defaultAppointmentData), ); expect(instance.$element().hasClass(APPOINTMENT_CLASSES.CONTAINER)).toBe(true); @@ -61,7 +61,7 @@ describe('BaseAppointment', () => { true, false, ])('should have correct class for isRecurring = %o', async (isRecurring) => { const instance = await createBaseAppointment( - getBaseAppointmentProperties({ + getBaseAppointmentViewProperties({ ...defaultAppointmentData, recurrenceRule: isRecurring ? 'FREQ=DAILY;COUNT=5' : undefined, }), @@ -76,7 +76,7 @@ describe('BaseAppointment', () => { true, false, ])('should have correct class for allDay = %o', async (allDay) => { const instance = await createBaseAppointment( - getBaseAppointmentProperties({ + getBaseAppointmentViewProperties({ ...defaultAppointmentData, allDay, }), @@ -91,7 +91,7 @@ describe('BaseAppointment', () => { describe('Aria', () => { it('should have role button', async () => { const instance = await createBaseAppointment( - getBaseAppointmentProperties(defaultAppointmentData), + getBaseAppointmentViewProperties(defaultAppointmentData), ); expect(instance.$element().attr('role')).toBe('button'); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts index 51cb121cc754..6700880e64ac 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts @@ -3,31 +3,42 @@ import registerComponent from '@js/core/component_registrator'; import type { DxElement } from '@js/core/element'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import type { DxEvent } from '@js/events'; import { getPublicElement } from '@ts/core/m_element'; import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; import { FunctionTemplate } from '@ts/core/templates/m_function_template'; import type { TemplateBase } from '@ts/core/templates/m_template_base'; import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; import DOMComponent from '@ts/core/widget/dom_component'; +import type { OptionChanged } from '@ts/core/widget/types'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; +import { click, focus, keyboard } from '@ts/events/m_short'; import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor'; -import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const'; +import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES, FOCUSED_STATE_CLASS } from '../const'; +import type { ViewItem } from '../types'; import { DateFormatType, getDateTextFromTargetAppointment } from '../utils/get_date_text'; +const EVENTS_NAMESPACE = { namespace: 'dxSchedulerAppointment' }; + export interface BaseAppointmentViewProperties // eslint-disable-next-line @typescript-eslint/no-explicit-any extends DOMComponentProperties> { index: number; + tabIndex: number; appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment; appointmentTemplate: TemplateBase; - onAppointmentRendered: (e: { + onRendered: (e: { element: DxElement; appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment; }) => void; + onFocusIn: () => void; + onFocusOut: (e: DxEvent) => void; + onKeyDown: (e: KeyboardKeyDownEvent) => void; getDataAccessor: () => AppointmentDataAccessor; getResourceColor: () => Promise; @@ -35,7 +46,8 @@ export interface BaseAppointmentViewProperties export class BaseAppointmentView< TProperties extends BaseAppointmentViewProperties = BaseAppointmentViewProperties, -> extends DOMComponent, TProperties> { +> extends DOMComponent, TProperties> + implements ViewItem { protected get targetedAppointmentData(): TargetedAppointment { return this.option().targetedAppointmentData; } @@ -46,6 +58,8 @@ export class BaseAppointmentView< private defaultAppointmentTemplate!: FunctionTemplate; + private keyboardListenerId?: string; + override _init(): void { super._init(); @@ -60,11 +74,36 @@ export class BaseAppointmentView< this.resize(); this.applyElementClasses(); this.applyAria(); + this.attachFocusEvents(); + this.attachClickEvent(); + this.attachKeydownEvents(); this.renderContentTemplate(); } + override _optionChanged(args: OptionChanged): void { + switch (args.name) { + case 'tabIndex': { + if (this.$element().attr('tabindex') !== '-1') { + this.makeFocusable(); + } + break; + } + default: + break; + } + } + public resize(): void { } + public focus(): void { + this.makeFocusable(); + focus.trigger(this.$element()); + } + + public makeFocusable(): void { + this.$element().attr('tabindex', this.option().tabIndex); + } + protected applyElementClasses(): void { this.$element() .addClass(APPOINTMENT_CLASSES.CONTAINER) @@ -74,7 +113,58 @@ export class BaseAppointmentView< protected applyAria(): void { this.$element() - .attr('role', 'button'); + .attr('role', 'button') + .attr('tabindex', -1); + } + + private attachFocusEvents(): void { + focus.off(this.$element(), EVENTS_NAMESPACE); + focus.on( + this.$element(), + this.onFocusIn.bind(this), + this.onFocusOut.bind(this), + EVENTS_NAMESPACE, + ); + } + + private attachClickEvent(): void { + click.off(this.$element(), EVENTS_NAMESPACE); + click.on( + this.$element(), + this.onClick.bind(this), + EVENTS_NAMESPACE, + ); + } + + private attachKeydownEvents(): void { + keyboard.off(this.keyboardListenerId); + this.keyboardListenerId = keyboard.on( + this.$element(), + this.$element(), + this.onKeyDown.bind(this), + ); + } + + private onFocusIn(): void { + this.$element().addClass(FOCUSED_STATE_CLASS); + + this.option().onFocusIn(); + } + + private onFocusOut(e: DxEvent): void { + this.$element() + .removeClass(FOCUSED_STATE_CLASS) + .attr('tabindex', -1); + + this.option().onFocusOut(e); + } + + private onClick(): void { + this.makeFocusable(); + } + + private onKeyDown(e: KeyboardKeyDownEvent): void { + this.option().onKeyDown(e); } protected getTitleText(): string { @@ -128,7 +218,7 @@ export class BaseAppointmentView< }, index: this.option().index, onRendered: () => { - this.option().onAppointmentRendered({ + this.option().onRendered({ element: getPublicElement(this.$element()), appointmentData: this.appointmentData, targetedAppointmentData: this.targetedAppointmentData, diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts index 8e3df06770f7..4be78ad04664 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.test.ts @@ -5,7 +5,7 @@ import $ from '@js/core/renderer'; import type { SafeAppointment } from '@ts/scheduler/types'; import fx from '../../../common/core/animation/fx'; -import { getBaseAppointmentProperties } from '../__mock__/appointment_properties'; +import { getBaseAppointmentViewProperties } from '../__mock__/appointment_properties'; import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const'; import type { GridAppointmentViewProperties } from './grid_appointment'; import { GridAppointmentView } from './grid_appointment'; @@ -13,7 +13,7 @@ import { GridAppointmentView } from './grid_appointment'; const getProperties = ( appointmentData: SafeAppointment, ): GridAppointmentViewProperties => { - const baseProperties = getBaseAppointmentProperties(appointmentData); + const baseProperties = getBaseAppointmentViewProperties(appointmentData); return { ...baseProperties, diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts index f8388dff4de3..f88edee5b703 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/grid_appointment.ts @@ -27,14 +27,16 @@ export class GridAppointmentView extends BaseAppointmentView {}, + onFocusOut: () => {}, + onKeyDown: () => {}, }; + + return config; }; const createAppointmentCollector = ( diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts index f220a29b2af4..03e84ef992e7 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts @@ -4,17 +4,22 @@ import registerComponent from '@js/core/component_registrator'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; import { EmptyTemplate } from '@js/core/templates/empty_template'; +import type { DxEvent } from '@js/events'; import Button from '@js/ui/button'; import { FunctionTemplate } from '@ts/core/templates/m_function_template'; import type { TemplateBase } from '@ts/core/templates/m_template_base'; import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; import DOMComponent from '@ts/core/widget/dom_component'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; +import { focus, keyboard } from '@ts/events/m_short'; import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; import { APPOINTMENT_COLLECTOR_CLASSES } from './const'; +import type { ViewItem } from './types'; export interface AppointmentCollectorProperties extends DOMComponentProperties { + tabIndex: number; appointmentsData: SafeAppointment[]; isCompact: boolean; geometry: { @@ -25,10 +30,17 @@ export interface AppointmentCollectorProperties }; targetedAppointmentData: TargetedAppointment; appointmentCollectorTemplate: TemplateBase; + + onFocusIn: () => void; + onFocusOut: (e: DxEvent) => void; + onKeyDown: (e: KeyboardKeyDownEvent) => void; } +const EVENTS_NAMESPACE = { namespace: 'dxSchedulerAppointmentCollector' }; + export class AppointmentCollector - extends DOMComponent { + extends DOMComponent + implements ViewItem { private defaultAppointmentCollectorTemplate!: FunctionTemplate; private buttonInstance?: Button; @@ -37,6 +49,8 @@ export class AppointmentCollector return this.option().appointmentsData.length; } + private keyboardListenerId?: string; + override _init(): void { super._init(); @@ -48,22 +62,34 @@ export class AppointmentCollector override _initMarkup(): void { super._initMarkup(); + this.resize(); this.applyElementClasses(); this.applyElementAria(); - this.resize(); + this.attachFocusEvents(); + this.attachKeydownEvents(); this.renderContentTemplate(); } - public resize(): void { - this.$element().css({ - top: this.option().geometry.top, - left: this.option().geometry.left, - }); + public resize( + geometry?: { height: number, width: number, top: number, left: number }, + ): void { + const newGeometry = geometry ?? this.option().geometry; + const { + top, left, width, height, + } = newGeometry; - this.buttonInstance?.option({ - width: this.option().geometry.width, - height: this.option().geometry.height, - }); + this.$element().css({ top, left }); + + this.buttonInstance?.option({ width, height }); + } + + public focus(): void { + this.makeFocusable(); + focus.trigger(this.$element()); + } + + public makeFocusable(): void { + this.buttonInstance?.option('tabIndex', this.option().tabIndex); } private applyElementClasses(): void { @@ -90,6 +116,43 @@ export class AppointmentCollector .attr('aria-roledescription', dateText); } + private attachFocusEvents(): void { + focus.off(this.$element(), EVENTS_NAMESPACE); + focus.on( + this.$element(), + this.onFocusIn.bind(this), + this.onFocusOut.bind(this), + EVENTS_NAMESPACE, + ); + } + + private attachKeydownEvents(): void { + keyboard.off(this.keyboardListenerId); + this.keyboardListenerId = keyboard.on( + this.$element(), + this.$element(), + this.onKeyDown.bind(this), + ); + } + + private onFocusIn(): void { + this.option().onFocusIn(); + } + + private onFocusOut(e: DxEvent): void { + this.buttonInstance?.option('tabIndex', -1); + + this.option().onFocusOut(e); + } + + private onKeyDown(e: KeyboardKeyDownEvent): void { + this.option().onKeyDown(e); + } + + private onClick(): void { + this.makeFocusable(); + } + private renderContentTemplate(): void { const template = this.option().appointmentCollectorTemplate instanceof EmptyTemplate ? this.defaultAppointmentCollectorTemplate @@ -97,6 +160,7 @@ export class AppointmentCollector this.buttonInstance = this._createComponent(this.$element(), Button, { type: 'default', + tabIndex: -1, width: this.option().geometry.width, height: this.option().geometry.height, // eslint-disable-next-line @typescript-eslint/no-unsafe-return @@ -108,6 +172,7 @@ export class AppointmentCollector items: this.option().appointmentsData, }, })), + onClick: this.onClick.bind(this), }); } 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 new file mode 100644 index 000000000000..7b8574676b62 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -0,0 +1,112 @@ +import $ from '@js/core/renderer'; +import type { DxEvent } from '@js/events'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; + +import { getRawAppointmentGroupValues } from '../utils/resource_manager/appointment_groups_utils'; +import type { SortedEntity } from '../view_model/types'; +import type { Appointments } from './appointments'; + +export class AppointmentsFocusController { + private focusedIndex = -1; + + private needRestoreFocusIndex = -1; + + private get sortedAppointments(): SortedEntity[] { + return this.appointments.option().getSortedAppointments(); + } + + private get isVirtualScrolling(): boolean { + return this.appointments.option().isVirtualScrolling(); + } + + constructor(private readonly appointments: Appointments) { } + + public onAppointmentFocusIn(sortedIndex: number): void { + this.focusedIndex = sortedIndex; + } + + public onAppointmentFocusOut(e: DxEvent, sortedIndex: number): void { + const focusEvent = e.originalEvent as FocusEvent; + + const $relatedTarget = $(focusEvent.relatedTarget as Element); + const { $commonContainer, $allDayContainer } = this.appointments; + + const isFocusOutside = $relatedTarget.length === 0 || ( + $relatedTarget.closest($commonContainer).length === 0 + && $relatedTarget?.closest($allDayContainer ?? $()).length === 0 + ); + + if (isFocusOutside) { + this.focusedIndex = -1; + this.resetTabIndex(); + } + } + + public onAppointmentKeyDown(e: KeyboardKeyDownEvent, sortedIndex: number): void { + if (e.key === 'Tab') { + this.handleTabPress(e, sortedIndex); + } + } + + public beforeRender(): void { + // TODO: support case when appointment is deleted or updated + + // if (this.needRestoreFocusIndex === -1) { + // this.needRestoreFocusIndex = this.focusedIndex; + // } + } + + public resetTabIndex(): void { + if (this.needRestoreFocusIndex >= 0) { + const appointmentView = this.appointments.getViewItem(this.needRestoreFocusIndex); + appointmentView?.focus(); + this.needRestoreFocusIndex = -1; + return; + } + + // TODO: in virtual scrolling first appointment may not be rendered + this.appointments.getViewItem(0)?.makeFocusable(); + } + + private handleTabPress(e: KeyboardKeyDownEvent, sortedIndex: number): void { + const nextIndex = sortedIndex + (e.shift ? -1 : 1); + const nextItemData = this.sortedAppointments[nextIndex]; + + if (!nextItemData) { + return; + } + + e.originalEvent.preventDefault(); + this.focusByItemData(nextItemData); + } + + private focusByItemData(itemData: SortedEntity): void { + if (this.isVirtualScrolling) { + this.scrollToByItemData(itemData); + } + + const appointmentView = this.appointments.getViewItem(itemData.sortedIndex); + + if (appointmentView) { + appointmentView.focus(); + } else if (this.isVirtualScrolling) { + this.needRestoreFocusIndex = itemData.sortedIndex; + } + } + + private scrollToByItemData(itemData: SortedEntity): void { + const { getStartViewDate, getResourceManager, scrollTo } = this.appointments.option(); + + const date = new Date(Math.max( + getStartViewDate().getTime(), + itemData.source.startDate, + )); + + const group = getRawAppointmentGroupValues( + itemData.itemData, + getResourceManager().resources, + ); + + scrollTo(date, { group, allDay: itemData.allDay }); + } +} diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 2d5eb89a7b41..5912b615247c 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -1,14 +1,18 @@ import registerComponent from '@js/core/component_registrator'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; +import type { DxEvent } from '@js/events'; import type { Properties as SchedulerProperties } from '@js/ui/scheduler'; import { domAdapter } from '@ts/core/m_dom_adapter'; import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; import DOMComponent from '@ts/core/widget/dom_component'; import type { OptionChanged } from '@ts/core/widget/types'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; -import type { SafeAppointment, TargetedAppointment, ViewType } from '../types'; +import type { + SafeAppointment, ScrollToOptions, TargetedAppointment, ViewType, +} from '../types'; import type { AppointmentDataAccessor } from '../utils/data_accessor/appointment_data_accessor'; import type { AppointmentResource } from '../utils/resource_manager/appointment_groups_utils'; import type { ResourceManager } from '../utils/resource_manager/resource_manager'; @@ -18,12 +22,15 @@ import type { AppointmentItemViewModel, AppointmentViewModelPlain, BaseAppointmentViewModel, + SortedEntity, } from '../view_model/types'; import { AgendaAppointmentView } from './appointment/agenda_appointment'; import type { BaseAppointmentViewProperties } from './appointment/base_appointment'; import { GridAppointmentView } from './appointment/grid_appointment'; import { AppointmentCollector } from './appointment_collector'; +import { AppointmentsFocusController } from './appointments.focus_controller'; import { APPOINTMENTS_CONTAINER_CLASS } from './const'; +import type { ViewItem } from './types'; import { getTargetedAppointment } from './utils/get_targeted_appointment'; import type { DiffItem } from './utils/get_view_model_diff'; import { getViewModelDiff } from './utils/get_view_model_diff'; @@ -31,6 +38,7 @@ import { isAgendaAppointmentViewModel, isCollectorViewModel as isAppointmentColl export interface AppointmentsProperties extends DOMComponentProperties { currentView: ViewType; + tabIndex: number; viewModel: AppointmentViewModelPlain[]; items: AppointmentViewModelPlain[]; // TODO: legacy compatibility $allDayContainer: dxElementWrapper | null; @@ -38,33 +46,52 @@ export interface AppointmentsProperties extends DOMComponentProperties Date; + getSortedAppointments: () => SortedEntity[]; + isVirtualScrolling: () => boolean; + scrollTo: ( + date: Date, + options?: ScrollToOptions, + ) => void; getAppointmentDataSource: () => AppointmentDataSource; getResourceManager: () => ResourceManager; getDataAccessor: () => AppointmentDataAccessor; } -type AppointmentComponent = GridAppointmentView | AgendaAppointmentView | AppointmentCollector; - export class Appointments extends DOMComponent { - private appointmentBySortIndex: Record = {}; + private focusController!: AppointmentsFocusController; + + private viewItemBySortedIndex: Record = {}; + + public getViewItem(sortedIndex: number): ViewItem | undefined { + return this.viewItemBySortedIndex[sortedIndex]; + } - private get $allDayContainer(): dxElementWrapper | null { + public get $allDayContainer(): dxElementWrapper | null { return this.option().$allDayContainer; } - private get $commonContainer(): dxElementWrapper { + public get $commonContainer(): dxElementWrapper { return this.$element(); } override _init(): void { super._init(); + this.focusController = new AppointmentsFocusController(this); + this._templateManager.addDefaultTemplates({ appointment: new EmptyTemplate(), appointmentCollector: new EmptyTemplate(), }); + + // TODO: legacy compatibility + if (this.option().appointmentTemplate === 'item') { + this.option('appointmentTemplate', 'appointment'); + } } override _initMarkup(): void { @@ -76,6 +103,7 @@ export class Appointments extends DOMComponent = {}; + const newViewItemBySortedIndex: Record = {}; const isRepaintAll = viewModelDiff.every( (item) => Boolean(item.needToAdd ?? item.needToRemove), @@ -196,55 +228,71 @@ export class Appointments extends DOMComponent'); fragment.appendChild($element.get(0)); const targetedAppointmentData = this.getTargetedAppointmentData(appointmentViewModel); + const { sortedIndex } = appointmentViewModel; + + const focusControllerHandlers = { + onFocusIn: (): void => { + this.focusController.onAppointmentFocusIn(sortedIndex); + }, + onFocusOut: (e: DxEvent): void => { + this.focusController.onAppointmentFocusOut(e, sortedIndex); + }, + onKeyDown: (e: KeyboardKeyDownEvent): void => { + this.focusController.onAppointmentKeyDown(e, sortedIndex); + }, + }; if (isAppointmentCollectorViewModel(appointmentViewModel)) { return this._createComponent($element, AppointmentCollector, { + ...focusControllerHandlers, + tabIndex: this.option().tabIndex, appointmentsData: appointmentViewModel.items.map((item) => item.itemData), isCompact: appointmentViewModel.isCompact, geometry: { @@ -259,12 +307,14 @@ export class Appointments extends DOMComponent this.getResourceColor(appointmentViewModel), getDataAccessor: this.option().getDataAccessor, }; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/const.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/const.ts index 6edeb75ce6cc..f879da2014ab 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/const.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/const.ts @@ -15,6 +15,7 @@ export const APPOINTMENT_TYPE_CLASSES = { EMPTY: 'dx-scheduler-appointment-empty', ALL_DAY: 'dx-scheduler-all-day-appointment', RECURRING: 'dx-scheduler-appointment-recurrence', + HAS_RESOURCE: 'dx-scheduler-appointment-has-resource-color', }; export const APPOINTMENT_CLASSES = { @@ -38,3 +39,5 @@ export const AGENDA_APPOINTMENT_CLASSES = { RESOURCE_ITEM: 'dx-scheduler-appointment-resource-item', RESOURCE_ITEM_VALUE: 'dx-scheduler-appointment-resource-item-value', }; + +export const FOCUSED_STATE_CLASS = 'dx-state-focused'; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/types.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/types.ts new file mode 100644 index 000000000000..4436d971706e --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/types.ts @@ -0,0 +1,13 @@ +import type { dxElementWrapper } from '@js/core/renderer'; + +export interface ViewItem { + focus: () => void; + + makeFocusable: () => void; + + resize: ( + geometry?: { height: number, width: number, top: number, left: number }, + ) => void; + + $element: () => dxElementWrapper; +} diff --git a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts index 2af79bd1fbfb..f0601ddf47ad 100644 --- a/packages/devextreme/js/__internal/scheduler/m_scheduler.ts +++ b/packages/devextreme/js/__internal/scheduler/m_scheduler.ts @@ -904,7 +904,7 @@ class Scheduler extends SchedulerOptionsBaseWidget { } } - isVirtualScrolling() { + isVirtualScrolling(): boolean { const workspace = this.getWorkSpace(); if (workspace) { @@ -1058,9 +1058,11 @@ class Scheduler extends SchedulerOptionsBaseWidget { if (this.option('_newAppointments')) { const appointmentsConfig: Partial = { + tabIndex: this.option('tabIndex'), currentView: this.option('currentView') as ViewType, appointmentTemplate: this.getViewOption('appointmentTemplate'), appointmentCollectorTemplate: this.getViewOption('appointmentCollectorTemplate'), + onAppointmentRendered: (e) => { // @ts-expect-error 'component' property is set by action this.appointmentRenderedAction({ @@ -1069,6 +1071,12 @@ class Scheduler extends SchedulerOptionsBaseWidget { targetedAppointmentData: e.targetedAppointmentData, }); }, + + getStartViewDate: () => this.getStartViewDate(), + getSortedAppointments: () => this._layoutManager.sortedItems, + isVirtualScrolling: () => this.isVirtualScrolling(), + scrollTo: this.scrollTo.bind(this), + getResourceManager: () => this.resourceManager, getAppointmentDataSource: () => this.appointmentDataSource, getDataAccessor: () => this._dataAccessors, From c79981c77d907c2f8e5a13ab2db2a82d05bbea67 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Mon, 13 Apr 2026 14:41:31 +0800 Subject: [PATCH 2/5] view item class & tests --- .../scheduler/appointment/regular/_index.scss | 1 + .../__mock__/appointment_collector.ts | 44 +++++ .../__mock__/appointment_properties.ts | 17 +- .../appointment/base_appointment.test.ts | 26 +-- .../appointment/base_appointment.ts | 82 +-------- .../appointment_collector.test.ts | 43 +---- .../appointments_new/appointment_collector.ts | 71 ++------ .../appointments.focus_controller.ts | 14 +- .../appointments_new/appointments.test.ts | 39 ++-- .../appointments_new/appointments.ts | 19 +- .../scheduler/appointments_new/types.ts | 13 -- .../appointments_new/view_item.test.ts | 169 ++++++++++++++++++ .../scheduler/appointments_new/view_item.ts | 89 +++++++++ 13 files changed, 391 insertions(+), 236 deletions(-) create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts delete mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/types.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts create mode 100644 packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts diff --git a/packages/devextreme-scss/scss/widgets/base/scheduler/appointment/regular/_index.scss b/packages/devextreme-scss/scss/widgets/base/scheduler/appointment/regular/_index.scss index b6779c03f5e5..3855fdb3bb56 100644 --- a/packages/devextreme-scss/scss/widgets/base/scheduler/appointment/regular/_index.scss +++ b/packages/devextreme-scss/scss/widgets/base/scheduler/appointment/regular/_index.scss @@ -94,6 +94,7 @@ $reduced-icon-offset: null !default; background-clip: padding-box; position: absolute; cursor: default; + outline: none; @include user-select(none); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts new file mode 100644 index 000000000000..a76d9e3afcc4 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts @@ -0,0 +1,44 @@ +import $ from '@js/core/renderer'; +import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; +import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; + +import type { AppointmentCollectorProperties } from '../appointment_collector'; +import { AppointmentCollector } from '../appointment_collector'; + +export const getAppointmentCollectorProperties = ( + appointmentsData: SafeAppointment[], +): AppointmentCollectorProperties => { + const targetedAppointmentData: TargetedAppointment = { + ...appointmentsData[0], + displayStartDate: appointmentsData[0].startDate as Date, + displayEndDate: appointmentsData[0].endDate as Date, + }; + + const config: AppointmentCollectorProperties = { + tabIndex: 0, + appointmentsData, + isCompact: false, + geometry: { + height: 30, + width: 30, + top: 0, + left: 0, + }, + targetedAppointmentData, + appointmentCollectorTemplate: new EmptyTemplate(), + onFocusIn: () => {}, + onFocusOut: () => {}, + onKeyDown: () => {}, + }; + + return config; +}; + +export const createAppointmentCollector = ( + properties: AppointmentCollectorProperties, +): AppointmentCollector => { + const $element = $('.root'); + + // @ts-expect-error + return new AppointmentCollector($element, properties); +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts index de5896af4b67..a5642e98636e 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts @@ -1,9 +1,10 @@ +import $ from '@js/core/renderer'; import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; import { mockAppointmentDataAccessor } from '@ts/scheduler/__mock__/appointment_data_accessor.mock'; import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor'; -import type { BaseAppointmentViewProperties } from '../appointment/base_appointment'; +import { BaseAppointmentView, type BaseAppointmentViewProperties } from '../appointment/base_appointment'; export const getBaseAppointmentViewProperties = ( appointmentData: SafeAppointment, @@ -31,3 +32,17 @@ export const getBaseAppointmentViewProperties = ( return config; }; + +export const createBaseAppointment = async ( + properties: BaseAppointmentViewProperties, +): Promise => { + const $element = $('.root'); + + // @ts-expect-error + const instance = new BaseAppointmentView($element, properties); + + // Await for resources + await new Promise(process.nextTick); + + return instance; +}; diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts index b5b102a19cd1..5e1d3c4bef71 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.test.ts @@ -4,24 +4,8 @@ import { import $ from '@js/core/renderer'; import fx from '../../../common/core/animation/fx'; -import { getBaseAppointmentViewProperties } from '../__mock__/appointment_properties'; +import { createBaseAppointment, getBaseAppointmentViewProperties as getProperties } from '../__mock__/appointment_properties'; import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const'; -import type { BaseAppointmentViewProperties } from './base_appointment'; -import { BaseAppointmentView } from './base_appointment'; - -const createBaseAppointment = async ( - properties: BaseAppointmentViewProperties, -): Promise => { - const $element = $('.root'); - - // @ts-expect-error - const instance = new BaseAppointmentView($element, properties); - - // Await for resources - await new Promise(process.nextTick); - - return instance; -}; const defaultAppointmentData = { title: 'Test appointment', @@ -51,7 +35,7 @@ describe('BaseAppointment', () => { describe('Classes', () => { it('should have container class', async () => { const instance = await createBaseAppointment( - getBaseAppointmentViewProperties(defaultAppointmentData), + getProperties(defaultAppointmentData), ); expect(instance.$element().hasClass(APPOINTMENT_CLASSES.CONTAINER)).toBe(true); @@ -61,7 +45,7 @@ describe('BaseAppointment', () => { true, false, ])('should have correct class for isRecurring = %o', async (isRecurring) => { const instance = await createBaseAppointment( - getBaseAppointmentViewProperties({ + getProperties({ ...defaultAppointmentData, recurrenceRule: isRecurring ? 'FREQ=DAILY;COUNT=5' : undefined, }), @@ -76,7 +60,7 @@ describe('BaseAppointment', () => { true, false, ])('should have correct class for allDay = %o', async (allDay) => { const instance = await createBaseAppointment( - getBaseAppointmentViewProperties({ + getProperties({ ...defaultAppointmentData, allDay, }), @@ -91,7 +75,7 @@ describe('BaseAppointment', () => { describe('Aria', () => { it('should have role button', async () => { const instance = await createBaseAppointment( - getBaseAppointmentViewProperties(defaultAppointmentData), + getProperties(defaultAppointmentData), ); expect(instance.$element().attr('role')).toBe('button'); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts index 6700880e64ac..356e72b98c14 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts @@ -8,25 +8,18 @@ import { getPublicElement } from '@ts/core/m_element'; import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; import { FunctionTemplate } from '@ts/core/templates/m_function_template'; import type { TemplateBase } from '@ts/core/templates/m_template_base'; -import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; -import DOMComponent from '@ts/core/widget/dom_component'; -import type { OptionChanged } from '@ts/core/widget/types'; -import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; -import { click, focus, keyboard } from '@ts/events/m_short'; +import { click } from '@ts/events/m_short'; import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor'; import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES, FOCUSED_STATE_CLASS } from '../const'; -import type { ViewItem } from '../types'; import { DateFormatType, getDateTextFromTargetAppointment } from '../utils/get_date_text'; - -const EVENTS_NAMESPACE = { namespace: 'dxSchedulerAppointment' }; +import type { ViewItemProperties } from '../view_item'; +import { EVENTS_NAMESPACE, ViewItem } from '../view_item'; export interface BaseAppointmentViewProperties - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extends DOMComponentProperties> { + extends ViewItemProperties { index: number; - tabIndex: number; appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment; appointmentTemplate: TemplateBase; @@ -36,9 +29,6 @@ export interface BaseAppointmentViewProperties appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment; }) => void; - onFocusIn: () => void; - onFocusOut: (e: DxEvent) => void; - onKeyDown: (e: KeyboardKeyDownEvent) => void; getDataAccessor: () => AppointmentDataAccessor; getResourceColor: () => Promise; @@ -46,8 +36,7 @@ export interface BaseAppointmentViewProperties export class BaseAppointmentView< TProperties extends BaseAppointmentViewProperties = BaseAppointmentViewProperties, -> extends DOMComponent, TProperties> - implements ViewItem { +> extends ViewItem { protected get targetedAppointmentData(): TargetedAppointment { return this.option().targetedAppointmentData; } @@ -58,8 +47,6 @@ export class BaseAppointmentView< private defaultAppointmentTemplate!: FunctionTemplate; - private keyboardListenerId?: string; - override _init(): void { super._init(); @@ -80,30 +67,6 @@ export class BaseAppointmentView< this.renderContentTemplate(); } - override _optionChanged(args: OptionChanged): void { - switch (args.name) { - case 'tabIndex': { - if (this.$element().attr('tabindex') !== '-1') { - this.makeFocusable(); - } - break; - } - default: - break; - } - } - - public resize(): void { } - - public focus(): void { - this.makeFocusable(); - focus.trigger(this.$element()); - } - - public makeFocusable(): void { - this.$element().attr('tabindex', this.option().tabIndex); - } - protected applyElementClasses(): void { this.$element() .addClass(APPOINTMENT_CLASSES.CONTAINER) @@ -117,16 +80,6 @@ export class BaseAppointmentView< .attr('tabindex', -1); } - private attachFocusEvents(): void { - focus.off(this.$element(), EVENTS_NAMESPACE); - focus.on( - this.$element(), - this.onFocusIn.bind(this), - this.onFocusOut.bind(this), - EVENTS_NAMESPACE, - ); - } - private attachClickEvent(): void { click.off(this.$element(), EVENTS_NAMESPACE); click.on( @@ -136,35 +89,18 @@ export class BaseAppointmentView< ); } - private attachKeydownEvents(): void { - keyboard.off(this.keyboardListenerId); - this.keyboardListenerId = keyboard.on( - this.$element(), - this.$element(), - this.onKeyDown.bind(this), - ); - } - - private onFocusIn(): void { + protected override onFocusIn(): void { this.$element().addClass(FOCUSED_STATE_CLASS); - this.option().onFocusIn(); + super.onFocusIn(); } - private onFocusOut(e: DxEvent): void { + protected override onFocusOut(e: DxEvent): void { this.$element() .removeClass(FOCUSED_STATE_CLASS) .attr('tabindex', -1); - this.option().onFocusOut(e); - } - - private onClick(): void { - this.makeFocusable(); - } - - private onKeyDown(e: KeyboardKeyDownEvent): void { - this.option().onKeyDown(e); + super.onFocusOut(e); } protected getTitleText(): string { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.test.ts index 36e5294962fc..75b49f0f1055 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.test.ts @@ -2,52 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, jest, } from '@jest/globals'; import $ from '@js/core/renderer'; -import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; import fx from '../../../common/core/animation/fx'; -import type { SafeAppointment, TargetedAppointment } from '../types'; -import type { AppointmentCollectorProperties } from './appointment_collector'; -import { AppointmentCollector } from './appointment_collector'; +import { createAppointmentCollector, getAppointmentCollectorProperties as getProperties } from './__mock__/appointment_collector'; import { APPOINTMENT_COLLECTOR_CLASSES } from './const'; -const getProperties = ( - appointmentsData: SafeAppointment[], -): AppointmentCollectorProperties => { - const targetedAppointmentData: TargetedAppointment = { - ...appointmentsData[0], - displayStartDate: appointmentsData[0].startDate as Date, - displayEndDate: appointmentsData[0].endDate as Date, - }; - - const config: AppointmentCollectorProperties = { - tabIndex: 0, - appointmentsData, - isCompact: false, - geometry: { - height: 30, - width: 30, - top: 0, - left: 0, - }, - targetedAppointmentData, - appointmentCollectorTemplate: new EmptyTemplate(), - onFocusIn: () => {}, - onFocusOut: () => {}, - onKeyDown: () => {}, - }; - - return config; -}; - -const createAppointmentCollector = ( - properties: AppointmentCollectorProperties, -): AppointmentCollector => { - const $element = $('.root'); - - // @ts-expect-error - return new AppointmentCollector($element, properties); -}; - const defaultAppointmentData = { title: 'Test appointment', startDate: new Date(2024, 0, 1, 9, 0), diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts index 03e84ef992e7..586c89ee4447 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts @@ -8,18 +8,14 @@ import type { DxEvent } from '@js/events'; import Button from '@js/ui/button'; import { FunctionTemplate } from '@ts/core/templates/m_function_template'; import type { TemplateBase } from '@ts/core/templates/m_template_base'; -import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; -import DOMComponent from '@ts/core/widget/dom_component'; -import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; -import { focus, keyboard } from '@ts/events/m_short'; import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; import { APPOINTMENT_COLLECTOR_CLASSES } from './const'; -import type { ViewItem } from './types'; +import type { ViewItemProperties } from './view_item'; +import { ViewItem } from './view_item'; export interface AppointmentCollectorProperties - extends DOMComponentProperties { - tabIndex: number; + extends ViewItemProperties { appointmentsData: SafeAppointment[]; isCompact: boolean; geometry: { @@ -30,17 +26,10 @@ export interface AppointmentCollectorProperties }; targetedAppointmentData: TargetedAppointment; appointmentCollectorTemplate: TemplateBase; - - onFocusIn: () => void; - onFocusOut: (e: DxEvent) => void; - onKeyDown: (e: KeyboardKeyDownEvent) => void; } -const EVENTS_NAMESPACE = { namespace: 'dxSchedulerAppointmentCollector' }; - export class AppointmentCollector - extends DOMComponent - implements ViewItem { + extends ViewItem { private defaultAppointmentCollectorTemplate!: FunctionTemplate; private buttonInstance?: Button; @@ -49,8 +38,6 @@ export class AppointmentCollector return this.option().appointmentsData.length; } - private keyboardListenerId?: string; - override _init(): void { super._init(); @@ -70,7 +57,7 @@ export class AppointmentCollector this.renderContentTemplate(); } - public resize( + public override resize( geometry?: { height: number, width: number, top: number, left: number }, ): void { const newGeometry = geometry ?? this.option().geometry; @@ -83,13 +70,14 @@ export class AppointmentCollector this.buttonInstance?.option({ width, height }); } - public focus(): void { - this.makeFocusable(); - focus.trigger(this.$element()); + public override makeFocusable(): void { + this.buttonInstance?.option('tabIndex', this.option().tabIndex); } - public makeFocusable(): void { - this.buttonInstance?.option('tabIndex', this.option().tabIndex); + protected override onFocusOut(e: DxEvent): void { + this.buttonInstance?.option('tabIndex', -1); + + super.onFocusOut(e); } private applyElementClasses(): void { @@ -116,43 +104,6 @@ export class AppointmentCollector .attr('aria-roledescription', dateText); } - private attachFocusEvents(): void { - focus.off(this.$element(), EVENTS_NAMESPACE); - focus.on( - this.$element(), - this.onFocusIn.bind(this), - this.onFocusOut.bind(this), - EVENTS_NAMESPACE, - ); - } - - private attachKeydownEvents(): void { - keyboard.off(this.keyboardListenerId); - this.keyboardListenerId = keyboard.on( - this.$element(), - this.$element(), - this.onKeyDown.bind(this), - ); - } - - private onFocusIn(): void { - this.option().onFocusIn(); - } - - private onFocusOut(e: DxEvent): void { - this.buttonInstance?.option('tabIndex', -1); - - this.option().onFocusOut(e); - } - - private onKeyDown(e: KeyboardKeyDownEvent): void { - this.option().onKeyDown(e); - } - - private onClick(): void { - this.makeFocusable(); - } - private renderContentTemplate(): void { const template = this.option().appointmentCollectorTemplate instanceof EmptyTemplate ? this.defaultAppointmentCollectorTemplate 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 7b8574676b62..48bef667f1ab 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 @@ -44,7 +44,7 @@ export class AppointmentsFocusController { public onAppointmentKeyDown(e: KeyboardKeyDownEvent, sortedIndex: number): void { if (e.key === 'Tab') { - this.handleTabPress(e, sortedIndex); + this.handleTabKeyDown(e, sortedIndex); } } @@ -58,17 +58,19 @@ export class AppointmentsFocusController { public resetTabIndex(): void { if (this.needRestoreFocusIndex >= 0) { - const appointmentView = this.appointments.getViewItem(this.needRestoreFocusIndex); + const appointmentView = this.appointments.getViewItemBySortedIndex( + this.needRestoreFocusIndex, + ); appointmentView?.focus(); this.needRestoreFocusIndex = -1; return; } - // TODO: in virtual scrolling first appointment may not be rendered - this.appointments.getViewItem(0)?.makeFocusable(); + // TODO: in virtual scrolling no appointment may be rendered in the initial viewport + this.appointments.getViewItemByIndex(0)?.makeFocusable(); } - private handleTabPress(e: KeyboardKeyDownEvent, sortedIndex: number): void { + private handleTabKeyDown(e: KeyboardKeyDownEvent, sortedIndex: number): void { const nextIndex = sortedIndex + (e.shift ? -1 : 1); const nextItemData = this.sortedAppointments[nextIndex]; @@ -85,7 +87,7 @@ export class AppointmentsFocusController { this.scrollToByItemData(itemData); } - const appointmentView = this.appointments.getViewItem(itemData.sortedIndex); + const appointmentView = this.appointments.getViewItemBySortedIndex(itemData.sortedIndex); if (appointmentView) { appointmentView.focus(); 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 324ee673523c..842d53ba5e99 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -29,11 +29,25 @@ const mockAppointmentDataSource = (): AppointmentDataSource => ({ const getProperties = (options: { resources?: ResourceConfig[]; } = {}): AppointmentsProperties => ({ + currentView: 'week', + tabIndex: 0, + viewModel: [], + items: [], + $allDayContainer: $('
'), + appointmentTemplate: 'appointment', + appointmentCollectorTemplate: 'appointmentCollector', + + onAppointmentRendered: (): void => {}, + + getStartViewDate: () => new Date(2024, 0, 1), + getSortedAppointments: () => [], + isVirtualScrolling: () => false, + scrollTo: (): void => {}, + getAppointmentDataSource: mockAppointmentDataSource, getResourceManager: () => getResourceManagerMock(options.resources ?? []), getDataAccessor: () => mockAppointmentDataAccessor, - currentView: 'week', -} as AppointmentsProperties); +}); const createAppointments = ( properties?: AppointmentsProperties, @@ -139,26 +153,18 @@ describe('Appointments', () => { }); it('should render allDay appointment to the allDay container', () => { - const $allDayContainer = $('.allday-container'); - - const instance = createAppointments({ - ...getProperties(), - $allDayContainer, - }); + const instance = createAppointments(getProperties()); instance.option('viewModel', [ mockGridViewModel({ ...defaultAppointmentData, allDay: true }, { sortedIndex: 0 }), ]); expect(instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(0); - expect($allDayContainer.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); + expect(instance.option().$allDayContainer?.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); }); it('should not render allDay agenda appointment to the allDay container', () => { - const $allDayContainer = $('.allday-container'); - const instance = createAppointments({ ...getProperties(), - $allDayContainer, currentView: 'agenda', }); instance.option('viewModel', [ @@ -166,29 +172,26 @@ describe('Appointments', () => { ]); expect(instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); - expect($allDayContainer.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(0); + expect(instance.option().$allDayContainer?.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(0); }); it('should clean all day container when switching from grid view to agenda view', () => { - const $allDayContainer = $('.allday-container'); - const instance = createAppointments({ ...getProperties(), currentView: 'week', - $allDayContainer, }); instance.option('viewModel', [ mockGridViewModel({ ...defaultAppointmentData, allDay: true }, { sortedIndex: 0 }), ]); - expect($allDayContainer.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); + expect(instance.option().$allDayContainer?.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); instance.option('currentView', 'agenda'); instance.option('viewModel', [ mockAgendaViewModel({ ...defaultAppointmentData, allDay: true }, { sortedIndex: 0 }), ]); - expect($allDayContainer.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(0); + expect(instance.option().$allDayContainer?.find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(0); expect(instance.$element().find(`.${APPOINTMENT_CLASSES.CONTAINER}`).length).toBe(1); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 5912b615247c..49dc85ca52aa 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -30,11 +30,11 @@ import { GridAppointmentView } from './appointment/grid_appointment'; import { AppointmentCollector } from './appointment_collector'; import { AppointmentsFocusController } from './appointments.focus_controller'; import { APPOINTMENTS_CONTAINER_CLASS } from './const'; -import type { ViewItem } from './types'; import { getTargetedAppointment } from './utils/get_targeted_appointment'; import type { DiffItem } from './utils/get_view_model_diff'; import { getViewModelDiff } from './utils/get_view_model_diff'; import { isAgendaAppointmentViewModel, isCollectorViewModel as isAppointmentCollectorViewModel, isGridAppointmentViewModel } from './utils/type_helpers'; +import type { ViewItem } from './view_item'; export interface AppointmentsProperties extends DOMComponentProperties { currentView: ViewType; @@ -66,7 +66,13 @@ export class Appointments extends DOMComponent = {}; - public getViewItem(sortedIndex: number): ViewItem | undefined { + private viewItems: ViewItem[] = []; + + public getViewItemByIndex(index: number): ViewItem | undefined { + return this.viewItems[index]; + } + + public getViewItemBySortedIndex(sortedIndex: number): ViewItem | undefined { return this.viewItemBySortedIndex[sortedIndex]; } @@ -142,6 +148,13 @@ export class Appointments extends DOMComponent { + item.setTabIndex(args.value); + }); + this.renderAppointments(this.option().viewModel); + break; + } default: break; } @@ -193,6 +206,7 @@ export class Appointments extends DOMComponent void; - - makeFocusable: () => void; - - resize: ( - geometry?: { height: number, width: number, top: number, left: number }, - ) => void; - - $element: () => dxElementWrapper; -} diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts new file mode 100644 index 000000000000..ee6185d42e14 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts @@ -0,0 +1,169 @@ +import { + afterEach, beforeEach, describe, expect, it, jest, +} from '@jest/globals'; +import $ from '@js/core/renderer'; +import fx from '@ts/common/core/animation/fx'; + +import { createAppointmentCollector, getAppointmentCollectorProperties } from './__mock__/appointment_collector'; +import { createBaseAppointment, getBaseAppointmentViewProperties } from './__mock__/appointment_properties'; +import type { BaseAppointmentView, BaseAppointmentViewProperties } from './appointment/base_appointment'; +import type { AppointmentCollector, AppointmentCollectorProperties } from './appointment_collector'; +import type { ViewItemProperties } from './view_item'; + +const defaultAppointmentData = { + title: 'Test appointment', + startDate: new Date(2024, 0, 1, 9, 0), + endDate: new Date(2024, 0, 1, 10, 0), +}; + +describe.each([ + 'BaseAppointment', + 'AppointmentCollector', +])('ViewItem Common - %s', (viewItemName) => { + beforeEach(() => { + fx.off = true; + + const $container = $('
') + .addClass('container') + .appendTo(document.body); + + $('
') + .addClass('root') + .appendTo($container); + }); + + afterEach(() => { + $('.container').remove(); + fx.off = false; + jest.useRealTimers(); + }); + + const createViewItem = ( + properties: Partial = {}, + ): Promise => { + const baseProperties: ViewItemProperties = { + tabIndex: 0, + onFocusIn: () => {}, + onFocusOut: () => {}, + onKeyDown: () => {}, + }; + + if (viewItemName === 'BaseAppointment') { + const extendedProperties: BaseAppointmentViewProperties = { + ...baseProperties, + ...getBaseAppointmentViewProperties(defaultAppointmentData), + ...properties, + }; + + return createBaseAppointment(extendedProperties); + } + + const extendedProperties: AppointmentCollectorProperties = { + ...baseProperties, + ...getAppointmentCollectorProperties([defaultAppointmentData]), + ...properties, + }; + + return Promise.resolve(createAppointmentCollector(extendedProperties)); + }; + + describe('Focus', () => { + it('should have tabindex -1 by default', async () => { + const instance = await createViewItem(); + + expect(instance.$element().attr('tabindex')).toBe('-1'); + }); + + it('should set tabindex attr on makeFocusable', async () => { + const instance = await createViewItem({ tabIndex: 2 }); + + instance.makeFocusable(); + + expect(instance.$element().attr('tabindex')).toBe('2'); + }); + + it('should update tabindex attr on setTabIndex if appointment is focusable', async () => { + const instance = await createViewItem(); + + instance.makeFocusable(); + instance.setTabIndex(1); + + expect(instance.$element().attr('tabindex')).toBe('1'); + }); + + it('should not update tabindex attr on setTabIndex if appointment is not focusable', async () => { + const instance = await createViewItem(); + + instance.setTabIndex(1); + + expect(instance.$element().attr('tabindex')).toBe('-1'); + }); + + it('should set correct tabindex after setTabIndex and makeFocusable calls', async () => { + const instance = await createViewItem(); + + instance.setTabIndex(1); + instance.makeFocusable(); + + expect(instance.$element().attr('tabindex')).toBe('1'); + }); + + it('should focus on element when focus method is called', async () => { + const instance = await createViewItem(); + + instance.focus(); + + expect(document.activeElement).toBe(instance.$element().get(0)); + }); + + it('should call onFocusIn callback on focus', async () => { + const onFocusIn = jest.fn(); + + const instance = await createViewItem({ onFocusIn }); + + instance.focus(); + + expect(onFocusIn).toHaveBeenCalled(); + expect(instance.$element().attr('tabindex')).toBe('0'); + expect(instance.$element().hasClass('dx-state-focused')).toBe(true); + }); + + it('should call onFocusOut callback on blur', async () => { + const onFocusOut = jest.fn(); + + const instance = await createViewItem({ onFocusOut }); + + instance.focus(); + (instance.$element().get(0) as HTMLElement).blur(); + + expect(onFocusOut).toHaveBeenCalled(); + expect(instance.$element().attr('tabindex')).toBe('-1'); + expect(instance.$element().hasClass('dx-state-focused')).toBe(false); + }); + + it('should be focusable after click', async () => { + const instance = await createViewItem({ tabIndex: 1 }); + + const element = instance.$element().get(0) as HTMLElement; + + element.click(); + + expect(element.getAttribute('tabindex')).toBe('1'); + expect(element.classList.contains('dx-state-focused')).toBe(true); + expect(document.activeElement).toBe(element); + }); + }); + + describe('Key down', () => { + it('should call onKeyDown callback on enter key press', async () => { + const onKeyDown = jest.fn(); + + const instance = await createViewItem({ onKeyDown }); + + instance.focus(); + instance.$element().get(0)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(onKeyDown).toHaveBeenCalledWith(expect.objectContaining({ key: 'Enter' })); + }); + }); +}); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts new file mode 100644 index 000000000000..6d3e132b8226 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts @@ -0,0 +1,89 @@ +import type { DxEvent } from '@js/events'; +import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; +import DOMComponent from '@ts/core/widget/dom_component'; +import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; +import { focus, keyboard } from '@ts/events/m_short'; + +export const EVENTS_NAMESPACE = { namespace: 'dxSchedulerViewItem' }; + +export interface ViewItemProperties + // eslint-disable-next-line @typescript-eslint/no-explicit-any + extends DOMComponentProperties> { + tabIndex: number; + onFocusIn: () => void; + onFocusOut: (e: DxEvent) => void; + onKeyDown: (e: KeyboardKeyDownEvent) => void; +} + +export class ViewItem< + TProperties extends ViewItemProperties = ViewItemProperties, +> extends DOMComponent, TProperties> { + private keyboardListenerId?: string; + + // eslint-disable-next-line @typescript-eslint/naming-convention + override _getSynchronizableOptionsForCreateComponent(): ( + keyof DOMComponentProperties, TProperties>> + )[] { + // @ts-expect-error + return super._getSynchronizableOptionsForCreateComponent(); + } + + public resize( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + geometry?: { height: number; width: number | string; top: number; left: number }, + ): void {} + + public focus(): void { + this.makeFocusable(); + focus.trigger(this.$element()); + } + + public makeFocusable(): void { + this.$element().attr('tabindex', this.option().tabIndex); + } + + public setTabIndex(tabIndex: number | undefined): void { + // this.option().tabIndex; + this.option('tabIndex', tabIndex); + + if (this.$element().attr('tabindex') !== '-1') { + this.makeFocusable(); + } + } + + protected attachFocusEvents(): void { + const eventsNamespace = EVENTS_NAMESPACE; + focus.off(this.$element(), eventsNamespace); + focus.on( + this.$element(), + this.onFocusIn.bind(this), + this.onFocusOut.bind(this), + eventsNamespace, + ); + } + + protected attachKeydownEvents(): void { + keyboard.off(this.keyboardListenerId); + this.keyboardListenerId = keyboard.on( + this.$element(), + this.$element(), + this.onKeyDown.bind(this), + ); + } + + protected onFocusIn(): void { + this.option().onFocusIn(); + } + + protected onFocusOut(e: DxEvent): void { + this.option().onFocusOut(e); + } + + protected onClick(): void { + this.focus(); + } + + private onKeyDown(e: KeyboardKeyDownEvent): void { + this.option().onKeyDown(e); + } +} From 4141d1598279049bac7e02ae58f7c52d2c8b8755 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Mon, 13 Apr 2026 16:50:03 +0800 Subject: [PATCH 3/5] fix stale sortedIndex bug --- .../__mock__/appointment_collector.ts | 1 + .../__mock__/appointment_properties.ts | 1 + .../appointments_new/appointments.ts | 58 ++++++------- .../utils/get_view_model_diff.test.ts | 81 +++++++++++++------ .../utils/get_view_model_diff.ts | 5 +- .../appointments_new/view_item.test.ts | 1 + .../scheduler/appointments_new/view_item.ts | 14 ++-- 7 files changed, 96 insertions(+), 65 deletions(-) diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts index a76d9e3afcc4..34828b78d721 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts @@ -16,6 +16,7 @@ export const getAppointmentCollectorProperties = ( const config: AppointmentCollectorProperties = { tabIndex: 0, + sortedIndex: 0, appointmentsData, isCompact: false, geometry: { diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts index a5642e98636e..f74399dda0e2 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_properties.ts @@ -19,6 +19,7 @@ export const getBaseAppointmentViewProperties = ( const config: BaseAppointmentViewProperties = { index: 0, tabIndex: 0, + sortedIndex: 0, appointmentData, targetedAppointmentData: normalizedTargetedAppointmentData, appointmentTemplate: new EmptyTemplate(), diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts index 49dc85ca52aa..5a6f7bf87692 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.ts @@ -1,14 +1,12 @@ import registerComponent from '@js/core/component_registrator'; import type { dxElementWrapper } from '@js/core/renderer'; import $ from '@js/core/renderer'; -import type { DxEvent } from '@js/events'; import type { Properties as SchedulerProperties } from '@js/ui/scheduler'; import { domAdapter } from '@ts/core/m_dom_adapter'; import { EmptyTemplate } from '@ts/core/templates/m_empty_template'; import type { DOMComponentProperties } from '@ts/core/widget/dom_component'; import DOMComponent from '@ts/core/widget/dom_component'; import type { OptionChanged } from '@ts/core/widget/types'; -import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor'; import type { SafeAppointment, ScrollToOptions, TargetedAppointment, ViewType, @@ -207,9 +205,8 @@ export class Appointments extends DOMComponent { const { allDay, sortedIndex } = diffItem.item; + const lookupIndex = diffItem.oldSortedIndex ?? sortedIndex; + const viewItem = this.viewItemBySortedIndex[lookupIndex]; switch (true) { case diffItem.needToRemove: { @@ -242,39 +241,39 @@ export class Appointments extends DOMComponent { - this.focusController.onAppointmentFocusIn(sortedIndex); - }, - onFocusOut: (e: DxEvent): void => { - this.focusController.onAppointmentFocusOut(e, sortedIndex); - }, - onKeyDown: (e: KeyboardKeyDownEvent): void => { - this.focusController.onAppointmentKeyDown(e, sortedIndex); - }, + const baseViewItemConfig = { + tabIndex: this.option().tabIndex, + sortedIndex: appointmentViewModel.sortedIndex, + onFocusIn: this.focusController.onAppointmentFocusIn.bind(this.focusController), + onFocusOut: this.focusController.onAppointmentFocusOut.bind(this.focusController), + onKeyDown: this.focusController.onAppointmentKeyDown.bind(this.focusController), }; if (isAppointmentCollectorViewModel(appointmentViewModel)) { return this._createComponent($element, AppointmentCollector, { - ...focusControllerHandlers, - tabIndex: this.option().tabIndex, + ...baseViewItemConfig, appointmentsData: appointmentViewModel.items.map((item) => item.itemData), isCompact: appointmentViewModel.isCompact, geometry: { @@ -321,10 +314,9 @@ export class Appointments extends DOMComponent { const data1: ItemData = {}; const data2: ItemData = {}; const data3: ItemData = {}; - const a = [makeItem(data1), makeItem(data2), makeItem(data3)]; + const a = [ + makeItem(data1, { sortedIndex: 0 }), + makeItem(data2, { sortedIndex: 1 }), + makeItem(data3, { sortedIndex: 2 }), + ]; const b = [makeItem(data1), makeItem(data2), makeItem(data3)]; const diff = getViewModelDiff(a, b, defaultDataSource); expect(getOperations(diff)).toBe('==='); - expect(diff).toEqual([{ item: b[0] }, { item: b[1] }, { item: b[2] }]); + expect(diff).toEqual([ + { item: b[0], oldSortedIndex: 0 }, + { item: b[1], oldSortedIndex: 1 }, + { item: b[2], oldSortedIndex: 2 }, + ]); }); it('should mark all as needToAdd when old list is empty', () => { @@ -108,17 +116,21 @@ describe('getViewModelDiff', () => { const data2: ItemData = {}; const data3: ItemData = {}; const data4: ItemData = {}; - const a = [makeItem(data1), makeItem(data2), makeItem(data4)]; + const a = [ + makeItem(data1, { sortedIndex: 0 }), + makeItem(data2, { sortedIndex: 1 }), + makeItem(data4, { sortedIndex: 2 }), + ]; const b = [makeItem(data1), makeItem(data3), makeItem(data4)]; const diff = getViewModelDiff(a, b, defaultDataSource); expect(getOperations(diff)).toBe('=+-='); expect(diff).toEqual([ - { item: b[0] }, + { item: b[0], oldSortedIndex: 0 }, { item: b[1], needToAdd: true }, { item: a[1], needToRemove: true }, - { item: b[2] }, + { item: b[2], oldSortedIndex: 2 }, ]); }); @@ -126,17 +138,21 @@ describe('getViewModelDiff', () => { const data1: ItemData = {}; const data2: ItemData = {}; const data4: ItemData = {}; - const a = [makeItem(data1), makeItem(data2), makeItem(data4)]; + const a = [ + makeItem(data1, { sortedIndex: 0 }), + makeItem(data2, { sortedIndex: 1 }), + makeItem(data4, { sortedIndex: 2 }), + ]; const b = [makeItem(data1), makeItem(data2, { rowIndex: 1 }), makeItem(data4)]; const diff = getViewModelDiff(a, b, defaultDataSource); expect(getOperations(diff)).toBe('=+-='); expect(diff).toEqual([ - { item: b[0] }, + { item: b[0], oldSortedIndex: 0 }, { item: b[1], needToAdd: true }, { item: a[1], needToRemove: true }, - { item: b[2] }, + { item: b[2], oldSortedIndex: 2 }, ]); }); @@ -145,7 +161,12 @@ describe('getViewModelDiff', () => { const data2: ItemData = {}; const data3: ItemData = {}; const data4: ItemData = {}; - const a = [makeItem(data1), makeItem(data2), makeItem(data3), makeItem(data4)]; + const a = [ + makeItem(data1, { sortedIndex: 0 }), + makeItem(data2, { sortedIndex: 1 }), + makeItem(data3, { sortedIndex: 2 }), + makeItem(data4, { sortedIndex: 3 }), + ]; const b = [makeItem(data4), makeItem(data1), makeItem(data2), makeItem(data3)]; const diff = getViewModelDiff(a, b, defaultDataSource); @@ -153,9 +174,9 @@ describe('getViewModelDiff', () => { expect(getOperations(diff)).toBe('+===-'); expect(diff).toEqual([ { item: b[0], needToAdd: true }, - { item: b[1] }, - { item: b[2] }, - { item: b[3] }, + { item: b[1], oldSortedIndex: 0 }, + { item: b[2], oldSortedIndex: 1 }, + { item: b[3], oldSortedIndex: 2 }, { item: a[3], needToRemove: true }, ]); }); @@ -166,7 +187,12 @@ describe('getViewModelDiff', () => { const data3: ItemData = {}; const data4: ItemData = {}; const data5: ItemData = {}; - const a = [makeItem(data1), makeItem(data2), makeItem(data3), makeItem(data4)]; + const a = [ + makeItem(data1, { sortedIndex: 0 }), + makeItem(data2, { sortedIndex: 1 }), + makeItem(data3, { sortedIndex: 2 }), + makeItem(data4, { sortedIndex: 3 }), + ]; const b = [makeItem(data4), makeItem(data1), makeItem(data5), makeItem(data3)]; const diff = getViewModelDiff(a, b, defaultDataSource); @@ -174,10 +200,10 @@ describe('getViewModelDiff', () => { expect(getOperations(diff)).toBe('+=+-=-'); expect(diff).toEqual([ { item: b[0], needToAdd: true }, - { item: b[1] }, + { item: b[1], oldSortedIndex: 0 }, { item: b[2], needToAdd: true }, { item: a[1], needToRemove: true }, - { item: b[3] }, + { item: b[3], oldSortedIndex: 2 }, { item: a[3], needToRemove: true }, ]); }); @@ -188,7 +214,12 @@ describe('getViewModelDiff', () => { const data3: ItemData = { myId: 2 }; const data4: ItemData = { myId: 3 }; const data5: ItemData = { myId: 4 }; - const a = [makeItem(data1), makeItem(data2), makeItem(data3), makeItem(data4)]; + const a = [ + makeItem(data1), + makeItem(data2, { sortedIndex: 5 }), + makeItem(data3), + makeItem(data4), + ]; // bItem1 uses the same data2 ref as a[1] but with a different sortedIndex, // which is not part of the comparison object — items are still considered equal. const bItem1 = makeItem(data2, { sortedIndex: 99 }); @@ -197,14 +228,14 @@ describe('getViewModelDiff', () => { const diff = getViewModelDiff(a, b, defaultDataSource); expect(getOperations(diff)).toBe('+-=+=-'); - expect(diff[2]).toEqual({ item: bItem1 }); + expect(diff[2]).toEqual({ item: bItem1, oldSortedIndex: 5 }); }); describe('needToResize', () => { it('should mark needToResize when only dimensions change for the same item', () => { const data1: ItemData = {}; const a = [makeItem(data1, { - left: 0, top: 0, height: 100, width: 200, + sortedIndex: 3, left: 0, top: 0, height: 100, width: 200, })]; const b = [makeItem(data1, { left: 10, top: 20, height: 50, width: 150, @@ -213,14 +244,18 @@ describe('getViewModelDiff', () => { const diff = getViewModelDiff(a, b, defaultDataSource); expect(getOperations(diff)).toBe('r'); - expect(diff).toEqual([{ item: b[0], needToResize: true }]); + expect(diff).toEqual([{ item: b[0], needToResize: true, oldSortedIndex: 3 }]); }); it('should mix needToResize with other operations', () => { const data1: ItemData = {}; const data2: ItemData = {}; const data3: ItemData = {}; - const a = [makeItem(data1), makeItem(data2), makeItem(data3)]; + const a = [ + makeItem(data1, { sortedIndex: 0 }), + makeItem(data2, { sortedIndex: 1 }), + makeItem(data3, { sortedIndex: 2 }), + ]; const b = [ makeItem(data1), makeItem(data2, { left: 50, top: 50 }), @@ -231,9 +266,9 @@ describe('getViewModelDiff', () => { expect(getOperations(diff)).toBe('=r='); expect(diff).toEqual([ - { item: b[0] }, - { item: b[1], needToResize: true }, - { item: b[2] }, + { item: b[0], oldSortedIndex: 0 }, + { item: b[1], needToResize: true, oldSortedIndex: 1 }, + { item: b[2], oldSortedIndex: 2 }, ]); }); }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.ts index 02213a5de55f..6e91fa23a0bb 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/utils/get_view_model_diff.ts @@ -12,6 +12,7 @@ export interface DiffItem { needToRemove?: boolean; needToResize?: boolean; item: AppointmentItemViewModel | AppointmentCollectorViewModel; + oldSortedIndex?: number; } const getObjectToCompare = (item: Item, includeDimensions: boolean): object => { @@ -92,9 +93,9 @@ function getArraysDiff(options: { if (match(ai, bj)) { if (equal(ai, bj)) { - result.push({ item: bj }); + result.push({ item: bj, oldSortedIndex: ai.sortedIndex }); } else if (canResize(ai, bj)) { - result.push({ item: bj, needToResize: true }); + result.push({ item: bj, needToResize: true, oldSortedIndex: ai.sortedIndex }); } else { result.push({ item: ai, needToRemove: true }); result.push({ item: bj, needToAdd: true }); diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts index ee6185d42e14..0d5830a8ab85 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.test.ts @@ -43,6 +43,7 @@ describe.each([ ): Promise => { const baseProperties: ViewItemProperties = { tabIndex: 0, + sortedIndex: 0, onFocusIn: () => {}, onFocusOut: () => {}, onKeyDown: () => {}, diff --git a/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts index 6d3e132b8226..3463e1d62547 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/view_item.ts @@ -10,9 +10,10 @@ export interface ViewItemProperties // eslint-disable-next-line @typescript-eslint/no-explicit-any extends DOMComponentProperties> { tabIndex: number; - onFocusIn: () => void; - onFocusOut: (e: DxEvent) => void; - onKeyDown: (e: KeyboardKeyDownEvent) => void; + sortedIndex: number; + onFocusIn: (sortedIndex: number) => void; + onFocusOut: (e: DxEvent, sortedIndex: number) => void; + onKeyDown: (e: KeyboardKeyDownEvent, sortedIndex: number) => void; } export class ViewItem< @@ -43,7 +44,6 @@ export class ViewItem< } public setTabIndex(tabIndex: number | undefined): void { - // this.option().tabIndex; this.option('tabIndex', tabIndex); if (this.$element().attr('tabindex') !== '-1') { @@ -72,11 +72,11 @@ export class ViewItem< } protected onFocusIn(): void { - this.option().onFocusIn(); + this.option().onFocusIn(this.option().sortedIndex); } protected onFocusOut(e: DxEvent): void { - this.option().onFocusOut(e); + this.option().onFocusOut(e, this.option().sortedIndex); } protected onClick(): void { @@ -84,6 +84,6 @@ export class ViewItem< } private onKeyDown(e: KeyboardKeyDownEvent): void { - this.option().onKeyDown(e); + this.option().onKeyDown(e, this.option().sortedIndex); } } From fa322198140c5eda277807f903dc61b7f32f9f46 Mon Sep 17 00:00:00 2001 From: Eldar Iusupzhanov Date: Mon, 13 Apr 2026 20:18:57 +0800 Subject: [PATCH 4/5] implement tests --- .../appointments.focus_controller.ts | 21 +- .../appointments_new/appointments.test.ts | 317 ++++++++++++++++++ .../appointments_new/appointments.ts | 9 +- .../appointments_new/view_item.test.ts | 11 +- 4 files changed, 333 insertions(+), 25 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 48bef667f1ab..36a0dff0dc4e 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 @@ -7,8 +7,6 @@ import type { SortedEntity } from '../view_model/types'; import type { Appointments } from './appointments'; export class AppointmentsFocusController { - private focusedIndex = -1; - private needRestoreFocusIndex = -1; private get sortedAppointments(): SortedEntity[] { @@ -21,10 +19,10 @@ export class AppointmentsFocusController { constructor(private readonly appointments: Appointments) { } - public onAppointmentFocusIn(sortedIndex: number): void { - this.focusedIndex = sortedIndex; - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public onAppointmentFocusIn(sortedIndex: number): void { } + // eslint-disable-next-line @typescript-eslint/no-unused-vars public onAppointmentFocusOut(e: DxEvent, sortedIndex: number): void { const focusEvent = e.originalEvent as FocusEvent; @@ -37,7 +35,6 @@ export class AppointmentsFocusController { ); if (isFocusOutside) { - this.focusedIndex = -1; this.resetTabIndex(); } } @@ -48,14 +45,6 @@ export class AppointmentsFocusController { } } - public beforeRender(): void { - // TODO: support case when appointment is deleted or updated - - // if (this.needRestoreFocusIndex === -1) { - // this.needRestoreFocusIndex = this.focusedIndex; - // } - } - public resetTabIndex(): void { if (this.needRestoreFocusIndex >= 0) { const appointmentView = this.appointments.getViewItemBySortedIndex( @@ -84,7 +73,7 @@ export class AppointmentsFocusController { private focusByItemData(itemData: SortedEntity): void { if (this.isVirtualScrolling) { - this.scrollToByItemData(itemData); + this.scrollToItem(itemData); } const appointmentView = this.appointments.getViewItemBySortedIndex(itemData.sortedIndex); @@ -96,7 +85,7 @@ export class AppointmentsFocusController { } } - private scrollToByItemData(itemData: SortedEntity): void { + private scrollToItem(itemData: SortedEntity): void { const { getStartViewDate, getResourceManager, scrollTo } = this.appointments.option(); const date = new Date(Math.max( 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 842d53ba5e99..5e56a1f186ae 100644 --- a/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts @@ -8,6 +8,7 @@ import { mockAppointmentDataAccessor } from '../__mock__/appointment_data_access import { getResourceManagerMock } from '../__mock__/resource_manager.mock'; import type { ResourceConfig } from '../utils/loader/types'; import type { AppointmentDataSource } from '../view_model/m_appointment_data_source'; +import type { AppointmentCollectorViewModel, AppointmentItemViewModel, SortedEntity } from '../view_model/types'; import { mockAgendaViewModel, mockAppointmentCollectorViewModel, @@ -351,6 +352,322 @@ describe('Appointments', () => { }); }); + describe('Options', () => { + it('should pass tabIndex change to view items', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]); + + instance.getViewItemByIndex(0)?.focus(); + + instance.option('tabIndex', 2); + + expect(instance.getViewItemByIndex(0)?.option('tabIndex')).toBe(2); + expect(instance.getViewItemByIndex(1)?.option('tabIndex')).toBe(2); + expect(instance.getViewItemByIndex(2)?.option('tabIndex')).toBe(2); + }); + + it('should not rerender view items on tabIndex change', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + ]); + + const viewItem0 = instance.getViewItemByIndex(0)?.$element().get(0); + const viewItem2 = instance.getViewItemByIndex(1)?.$element().get(0); + + instance.option('tabIndex', 2); + + expect(instance.getViewItemByIndex(0)?.$element().get(0)).toBe(viewItem0); + expect(instance.getViewItemByIndex(1)?.$element().get(0)).toBe(viewItem2); + }); + }); + + describe('Focus and keyboard navigation', () => { + describe('Basic navigation', () => { + it('should set tabindex=0 on first appointment and tabindex=-1 on others after render', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]); + + expect(instance.getViewItemByIndex(0)?.$element().attr('tabindex')).toBe('0'); + expect(instance.getViewItemByIndex(1)?.$element().attr('tabindex')).toBe('-1'); + expect(instance.getViewItemByIndex(2)?.$element().attr('tabindex')).toBe('-1'); + }); + + it('should restore tabindex=0 on first appointment and tabindex=-1 on others after rerender', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 0 }), + mockGridViewModel({ ...defaultAppointmentData }, { sortedIndex: 1 }), + mockAppointmentCollectorViewModel({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]); + + instance.option('appointmentTemplate', () => {}); + + expect(instance.getViewItemByIndex(0)?.$element().attr('tabindex')).toBe('0'); + expect(instance.getViewItemByIndex(1)?.$element().attr('tabindex')).toBe('-1'); + expect(instance.getViewItemByIndex(2)?.$element().attr('tabindex')).toBe('-1'); + }); + }); + + describe.each([ + 'appointment', + 'appointmentCollector', + ])('Basic navigation for %s', (type) => { + const createItem = ( + data: typeof defaultAppointmentData, + overrides: { sortedIndex: number }, + ): AppointmentItemViewModel | AppointmentCollectorViewModel => ( + type === 'appointmentCollector' + ? mockAppointmentCollectorViewModel(data, overrides) + : mockGridViewModel(data, overrides) + ); + + it('should move focus to next view item on Tab', () => { + const viewModel = [ + createItem({ ...defaultAppointmentData }, { sortedIndex: 0 }), + createItem({ ...defaultAppointmentData }, { sortedIndex: 1 }), + createItem({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + const viewItem1 = instance.getViewItemBySortedIndex(1); + + viewItem0?.focus(); + viewItem0?.$element().get(0).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), + ); + + expect(viewItem0?.$element().attr('tabindex')).toBe('-1'); + expect(viewItem1?.$element().attr('tabindex')).toBe('0'); + expect(document.activeElement).toBe(viewItem1?.$element().get(0) as HTMLElement); + }); + + it('should move focus to previous view item on Shift+Tab', () => { + const viewModel = [ + createItem({ ...defaultAppointmentData }, { sortedIndex: 0 }), + createItem({ ...defaultAppointmentData }, { sortedIndex: 1 }), + createItem({ ...defaultAppointmentData }, { sortedIndex: 2 }), + ]; + + const instance = createAppointments({ + ...getProperties(), + getSortedAppointments: () => viewModel as unknown as SortedEntity[], + }); + instance.option('viewModel', viewModel); + + const viewItem0 = instance.getViewItemBySortedIndex(0); + const viewItem1 = instance.getViewItemBySortedIndex(1); + + viewItem1?.focus(); + viewItem1?.$element().get(0).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }), + ); + + expect(viewItem0?.$element().attr('tabindex')).toBe('0'); + expect(viewItem1?.$element().attr('tabindex')).toBe('-1'); + expect(document.activeElement).toBe(viewItem0?.$element().get(0) as HTMLElement); + }); + + it('should focus view item on click', () => { + const instance = createAppointments(getProperties()); + instance.option('viewModel', [ + createItem(defaultAppointmentData, { sortedIndex: 0 }), + ]); + + const viewItem = instance.getViewItemBySortedIndex(0); + const element = viewItem?.$element().get(0) as HTMLElement; + element.click(); + + expect(element.getAttribute('tabindex')).toBe('0'); + expect(document.activeElement).toBe(element); + }); + + it('should reset focused state when focus moves outside the container', () => { + const externalButton = $('