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/__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_collector.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts new file mode 100644 index 000000000000..34828b78d721 --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts @@ -0,0 +1,45 @@ +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, + sortedIndex: 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 1a10a4fc3cd9..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 @@ -1,11 +1,12 @@ +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 getBaseAppointmentProperties = ( +export const getBaseAppointmentViewProperties = ( appointmentData: SafeAppointment, targetedAppointmentData?: TargetedAppointment, ): BaseAppointmentViewProperties => { @@ -17,13 +18,32 @@ export const getBaseAppointmentProperties = ( const config: BaseAppointmentViewProperties = { index: 0, + tabIndex: 0, + sortedIndex: 0, appointmentData, targetedAppointmentData: normalizedTargetedAppointmentData, appointmentTemplate: new EmptyTemplate(), - onAppointmentRendered: () => {}, + onRendered: () => {}, + onFocusIn: () => {}, + onFocusOut: () => {}, + onKeyDown: () => {}, getDataAccessor: (): AppointmentDataAccessor => mockAppointmentDataAccessor, getResourceColor: (): Promise => Promise.resolve(undefined), }; 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/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..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 { getBaseAppointmentProperties } 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( - getBaseAppointmentProperties(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( - getBaseAppointmentProperties({ + 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( - getBaseAppointmentProperties({ + getProperties({ ...defaultAppointmentData, allDay, }), @@ -91,7 +75,7 @@ describe('BaseAppointment', () => { describe('Aria', () => { it('should have role button', async () => { const instance = await createBaseAppointment( - getBaseAppointmentProperties(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 51cb121cc754..5e3a6a777011 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,27 +3,28 @@ 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 { 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 } from '../const'; +import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES, FOCUSED_STATE_CLASS } from '../const'; import { DateFormatType, getDateTextFromTargetAppointment } from '../utils/get_date_text'; +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; appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment; appointmentTemplate: TemplateBase; - onAppointmentRendered: (e: { + onRendered: (e: { element: DxElement; appointmentData: SafeAppointment; targetedAppointmentData: TargetedAppointment; @@ -35,7 +36,7 @@ export interface BaseAppointmentViewProperties export class BaseAppointmentView< TProperties extends BaseAppointmentViewProperties = BaseAppointmentViewProperties, -> extends DOMComponent, TProperties> { +> extends ViewItem { protected get targetedAppointmentData(): TargetedAppointment { return this.option().targetedAppointmentData; } @@ -60,10 +61,17 @@ export class BaseAppointmentView< this.resize(); this.applyElementClasses(); this.applyAria(); + this.attachFocusEvents(); + this.attachClickEvent(); + this.attachKeydownEvents(); this.renderContentTemplate(); } - public resize(): void { } + override _dispose(): void { + super._dispose(); + + click.off(this.$element(), EVENTS_NAMESPACE); + } protected applyElementClasses(): void { this.$element() @@ -74,7 +82,31 @@ export class BaseAppointmentView< protected applyAria(): void { this.$element() - .attr('role', 'button'); + .attr('role', 'button') + .attr('tabindex', -1); + } + + private attachClickEvent(): void { + click.off(this.$element(), EVENTS_NAMESPACE); + click.on( + this.$element(), + this.onClick.bind(this), + EVENTS_NAMESPACE, + ); + } + + protected override onFocusIn(): void { + this.$element().addClass(FOCUSED_STATE_CLASS); + + super.onFocusIn(); + } + + protected override onFocusOut(e: DxEvent): void { + this.$element() + .removeClass(FOCUSED_STATE_CLASS) + .attr('tabindex', -1); + + super.onFocusOut(e); } protected getTitleText(): string { @@ -128,7 +160,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 { - const targetedAppointmentData: TargetedAppointment = { - ...appointmentsData[0], - displayStartDate: appointmentsData[0].startDate as Date, - displayEndDate: appointmentsData[0].endDate as Date, - }; - - return { - appointmentsData, - isCompact: false, - geometry: { - height: 30, - width: 30, - top: 0, - left: 0, - }, - targetedAppointmentData, - appointmentCollectorTemplate: new EmptyTemplate(), - }; -}; - -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 f220a29b2af4..586c89ee4447 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,18 @@ 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 { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types'; import { APPOINTMENT_COLLECTOR_CLASSES } from './const'; +import type { ViewItemProperties } from './view_item'; +import { ViewItem } from './view_item'; export interface AppointmentCollectorProperties - extends DOMComponentProperties { + extends ViewItemProperties { appointmentsData: SafeAppointment[]; isCompact: boolean; geometry: { @@ -28,7 +29,7 @@ export interface AppointmentCollectorProperties } export class AppointmentCollector - extends DOMComponent { + extends ViewItem { private defaultAppointmentCollectorTemplate!: FunctionTemplate; private buttonInstance?: Button; @@ -48,22 +49,35 @@ 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 override 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 override 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 { @@ -97,6 +111,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 +123,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..36a0dff0dc4e --- /dev/null +++ b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.focus_controller.ts @@ -0,0 +1,103 @@ +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 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) { } + + // 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; + + 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.resetTabIndex(); + } + } + + public onAppointmentKeyDown(e: KeyboardKeyDownEvent, sortedIndex: number): void { + if (e.key === 'Tab') { + this.handleTabKeyDown(e, sortedIndex); + } + } + + public resetTabIndex(): void { + if (this.needRestoreFocusIndex >= 0) { + const appointmentView = this.appointments.getViewItemBySortedIndex( + this.needRestoreFocusIndex, + ); + appointmentView?.focus(); + this.needRestoreFocusIndex = -1; + return; + } + + // TODO: in virtual scrolling no appointment may be rendered in the initial viewport + this.appointments.getViewItemByIndex(0)?.makeFocusable(); + } + + private handleTabKeyDown(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.scrollToItem(itemData); + } + + const appointmentView = this.appointments.getViewItemBySortedIndex(itemData.sortedIndex); + + if (appointmentView) { + appointmentView.focus(); + } else if (this.isVirtualScrolling) { + this.needRestoreFocusIndex = itemData.sortedIndex; + } + } + + private scrollToItem(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.test.ts b/packages/devextreme/js/__internal/scheduler/appointments_new/appointments.test.ts index 324ee673523c..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, @@ -29,11 +30,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 +154,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 +173,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); }); @@ -348,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 = $('