Skip to content

Commit f632cc2

Browse files
authored
Scheduler - Appointments Refactoring - Focus state (#33269)
1 parent df58a30 commit f632cc2

File tree

13 files changed

+1020
-55
lines changed

13 files changed

+1020
-55
lines changed

packages/devextreme-scss/scss/widgets/base/scheduler/appointment/regular/_index.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ $reduced-icon-offset: null !default;
9494
background-clip: padding-box;
9595
position: absolute;
9696
cursor: default;
97+
outline: none;
9798

9899
@include user-select(none);
99100

packages/devextreme/js/__internal/scheduler/__tests__/appointments_new.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,44 @@ describe('New Appointments', () => {
2727
document.body.innerHTML = '';
2828
});
2929

30+
describe('Options', () => {
31+
describe('tabIndex', () => {
32+
it('should have correct tabIndex on init', async () => {
33+
const { POM } = await createScheduler({
34+
dataSource: [
35+
{ text: 'Appointment 1', startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) },
36+
{ text: 'Appointment 2', startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) },
37+
],
38+
currentView: 'day',
39+
currentDate: new Date(2015, 1, 9, 8),
40+
tabIndex: 2,
41+
});
42+
43+
const firstAppointment = POM.getAppointments()[0];
44+
45+
expect(firstAppointment.element.getAttribute('tabindex')).toBe('2');
46+
});
47+
48+
it('should have correct tabIndex on option change', async () => {
49+
const { POM, scheduler } = await createScheduler({
50+
dataSource: [
51+
{ text: 'Appointment 1', startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) },
52+
{ text: 'Appointment 2', startDate: new Date(2015, 1, 9, 8), endDate: new Date(2015, 1, 9, 9) },
53+
],
54+
currentView: 'day',
55+
currentDate: new Date(2015, 1, 9, 8),
56+
tabIndex: 1,
57+
});
58+
59+
scheduler.option('tabIndex', 2);
60+
61+
const firstAppointment = POM.getAppointments()[0];
62+
63+
expect(firstAppointment.element.getAttribute('tabindex')).toBe('2');
64+
});
65+
});
66+
});
67+
3068
describe('Templates', () => {
3169
describe.each([
3270
'appointmentTemplate',

packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/appointment_collector.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export const getAppointmentCollectorProperties = (
1515
};
1616

1717
const config: AppointmentCollectorProperties = {
18+
tabIndex: 0,
19+
sortedIndex: 0,
1820
appointmentsData,
1921
isCompact: false,
2022
geometry: {
@@ -25,6 +27,10 @@ export const getAppointmentCollectorProperties = (
2527
},
2628
targetedAppointmentData,
2729
appointmentCollectorTemplate: new EmptyTemplate(),
30+
onFocusIn: () => {},
31+
onFocusOut: () => {},
32+
onKeyDown: () => {},
33+
onClick: () => {},
2834
};
2935

3036
return config;

packages/devextreme/js/__internal/scheduler/appointments_new/__mock__/base_appointment_view.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,16 @@ export const getBaseAppointmentViewProperties = (
1818

1919
const config: BaseAppointmentViewProperties = {
2020
index: 0,
21+
tabIndex: 0,
22+
sortedIndex: 0,
2123
appointmentData,
2224
targetedAppointmentData: normalizedTargetedAppointmentData,
2325
appointmentTemplate: new EmptyTemplate(),
2426
onRendered: () => {},
27+
onFocusIn: () => {},
28+
onFocusOut: () => {},
29+
onClick: () => {},
30+
onKeyDown: () => {},
2531
getDataAccessor: (): AppointmentDataAccessor => mockAppointmentDataAccessor,
2632
getResourceColor: (): Promise<string | undefined> => Promise.resolve(undefined),
2733
};

packages/devextreme/js/__internal/scheduler/appointments_new/appointment/base_appointment.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,21 @@ import registerComponent from '@js/core/component_registrator';
33
import type { DxElement } from '@js/core/element';
44
import type { dxElementWrapper } from '@js/core/renderer';
55
import $ from '@js/core/renderer';
6+
import type { DxEvent } from '@js/events';
67
import { getPublicElement } from '@ts/core/m_element';
78
import { EmptyTemplate } from '@ts/core/templates/m_empty_template';
89
import { FunctionTemplate } from '@ts/core/templates/m_function_template';
910
import type { TemplateBase } from '@ts/core/templates/m_template_base';
10-
import type { DOMComponentProperties } from '@ts/core/widget/dom_component';
11-
import DOMComponent from '@ts/core/widget/dom_component';
11+
import { click } from '@ts/events/m_short';
1212
import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types';
1313
import type { AppointmentDataAccessor } from '@ts/scheduler/utils/data_accessor/appointment_data_accessor';
1414

15-
import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES } from '../const';
15+
import { APPOINTMENT_CLASSES, APPOINTMENT_TYPE_CLASSES, FOCUSED_STATE_CLASS } from '../const';
1616
import { DateFormatType, getDateTextFromTargetAppointment } from '../utils/get_date_text';
17+
import { EVENTS_NAMESPACE, ViewItem, type ViewItemProperties } from '../view_item';
1718

1819
export interface BaseAppointmentViewProperties
19-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20-
extends DOMComponentProperties<BaseAppointmentView<any>> {
20+
extends ViewItemProperties {
2121
index: number;
2222
appointmentData: SafeAppointment;
2323
targetedAppointmentData: TargetedAppointment;
@@ -35,7 +35,7 @@ export interface BaseAppointmentViewProperties
3535

3636
export class BaseAppointmentView<
3737
TProperties extends BaseAppointmentViewProperties = BaseAppointmentViewProperties,
38-
> extends DOMComponent<BaseAppointmentView<TProperties>, TProperties> {
38+
> extends ViewItem<TProperties> {
3939
protected get targetedAppointmentData(): TargetedAppointment {
4040
return this.option().targetedAppointmentData;
4141
}
@@ -60,13 +60,17 @@ export class BaseAppointmentView<
6060
this.resize();
6161
this.applyElementClasses();
6262
this.applyAria();
63+
this.attachFocusEvents();
64+
this.attachClickEvent();
65+
this.attachKeydownEvents();
6366
this.renderContentTemplate();
6467
}
6568

66-
public resize(
67-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
68-
geometry?: { height: number; width: number | string; top: number; left: number },
69-
): void { }
69+
override _dispose(): void {
70+
super._dispose();
71+
72+
click.off(this.$element(), EVENTS_NAMESPACE);
73+
}
7074

7175
protected applyElementClasses(): void {
7276
this.$element()
@@ -77,7 +81,35 @@ export class BaseAppointmentView<
7781

7882
protected applyAria(): void {
7983
this.$element()
80-
.attr('role', 'button');
84+
.attr('role', 'button')
85+
.attr('tabindex', this.option().tabIndex);
86+
}
87+
88+
private attachClickEvent(): void {
89+
click.off(this.$element(), EVENTS_NAMESPACE);
90+
click.on(
91+
this.$element(),
92+
this.onClick.bind(this),
93+
EVENTS_NAMESPACE,
94+
);
95+
}
96+
97+
protected override onFocusIn(): void {
98+
this.$element().addClass(FOCUSED_STATE_CLASS);
99+
100+
super.onFocusIn();
101+
}
102+
103+
protected override onFocusOut(e: DxEvent): void {
104+
this.$element().removeClass(FOCUSED_STATE_CLASS);
105+
106+
super.onFocusOut(e);
107+
}
108+
109+
public override setTabIndex(tabIndex: number | undefined): void {
110+
super.setTabIndex(tabIndex);
111+
112+
this.$element().attr('tabindex', tabIndex ?? null);
81113
}
82114

83115
protected getTitleText(): string {

packages/devextreme/js/__internal/scheduler/appointments_new/appointment_collector.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@ import { EmptyTemplate } from '@js/core/templates/empty_template';
77
import Button from '@js/ui/button';
88
import { FunctionTemplate } from '@ts/core/templates/m_function_template';
99
import type { TemplateBase } from '@ts/core/templates/m_template_base';
10-
import type { DOMComponentProperties } from '@ts/core/widget/dom_component';
11-
import DOMComponent from '@ts/core/widget/dom_component';
1210
import type { SafeAppointment, TargetedAppointment } from '@ts/scheduler/types';
1311

1412
import { APPOINTMENT_COLLECTOR_CLASSES } from './const';
13+
import type { ViewItemProperties } from './view_item';
14+
import { ViewItem } from './view_item';
1515

1616
export interface AppointmentCollectorProperties
17-
extends DOMComponentProperties<AppointmentCollector> {
17+
extends ViewItemProperties {
1818
appointmentsData: SafeAppointment[];
1919
isCompact: boolean;
2020
geometry: {
@@ -28,7 +28,7 @@ export interface AppointmentCollectorProperties
2828
}
2929

3030
export class AppointmentCollector
31-
extends DOMComponent<AppointmentCollector, AppointmentCollectorProperties> {
31+
extends ViewItem<AppointmentCollectorProperties> {
3232
private defaultAppointmentCollectorTemplate!: FunctionTemplate;
3333

3434
private buttonInstance?: Button;
@@ -51,10 +51,12 @@ export class AppointmentCollector
5151
this.resize();
5252
this.applyElementClasses();
5353
this.applyElementAria();
54+
this.attachFocusEvents();
55+
this.attachKeydownEvents();
5456
this.renderContentTemplate();
5557
}
5658

57-
public resize(
59+
public override resize(
5860
geometry?: { height: number; width: number; top: number; left: number },
5961
): void {
6062
const newGeometry = geometry ?? this.option().geometry;
@@ -67,6 +69,12 @@ export class AppointmentCollector
6769
this.buttonInstance?.option({ width, height });
6870
}
6971

72+
public override setTabIndex(tabIndex: number | undefined): void {
73+
super.setTabIndex(tabIndex);
74+
75+
this.buttonInstance?.option('tabIndex', tabIndex);
76+
}
77+
7078
private applyElementClasses(): void {
7179
this.$element()
7280
.addClass(APPOINTMENT_COLLECTOR_CLASSES.CONTAINER)
@@ -98,6 +106,7 @@ export class AppointmentCollector
98106

99107
this.buttonInstance = this._createComponent(this.$element(), Button, {
100108
type: 'default',
109+
tabIndex: this.option().tabIndex,
101110
width: this.option().geometry.width,
102111
height: this.option().geometry.height,
103112
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
@@ -109,6 +118,7 @@ export class AppointmentCollector
109118
items: this.option().appointmentsData,
110119
},
111120
})),
121+
onClick: this.onClick.bind(this),
112122
});
113123
}
114124

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import $ from '@js/core/renderer';
2+
import type { DxEvent } from '@js/events';
3+
import type { KeyboardKeyDownEvent } from '@ts/events/core/m_keyboard_processor';
4+
import { focus } from '@ts/events/m_short';
5+
6+
import { getRawAppointmentGroupValues } from '../utils/resource_manager/appointment_groups_utils';
7+
import type { SortedEntity } from '../view_model/types';
8+
import type { Appointments } from './appointments';
9+
import type { ViewItem } from './view_item';
10+
11+
export class AppointmentsFocusController {
12+
private focusableSortedIndex = 0;
13+
14+
private needRestoreFocusIndex = -1;
15+
16+
private get sortedAppointments(): SortedEntity[] {
17+
return this.appointments.option().getSortedAppointments();
18+
}
19+
20+
private get isVirtualScrolling(): boolean {
21+
return this.appointments.option().isVirtualScrolling();
22+
}
23+
24+
private get tabIndex(): number | undefined {
25+
return this.appointments.option().tabIndex;
26+
}
27+
28+
constructor(private readonly appointments: Appointments) { }
29+
30+
public onViewItemClick(viewItem: ViewItem): void {
31+
this.focusViewItem(viewItem);
32+
}
33+
34+
public onViewItemFocusIn(): void { }
35+
36+
public onViewItemFocusOut(e: DxEvent): void {
37+
const focusEvent = e.originalEvent as FocusEvent;
38+
39+
const $relatedTarget = $(focusEvent.relatedTarget as Element);
40+
const { $commonContainer, $allDayContainer } = this.appointments;
41+
42+
const isFocusOutside = $relatedTarget.length === 0 || (
43+
$relatedTarget.closest($commonContainer).length === 0
44+
&& $relatedTarget?.closest($allDayContainer ?? $()).length === 0
45+
);
46+
47+
if (isFocusOutside) {
48+
this.resetTabIndex(0);
49+
}
50+
}
51+
52+
public onViewItemKeyDown(viewItem: ViewItem, e: KeyboardKeyDownEvent): void {
53+
if (e.key === 'Tab') {
54+
this.handleTabKeyDown(e, viewItem.option().sortedIndex);
55+
}
56+
}
57+
58+
public resetTabIndex(newFocusableIndex?: number): void {
59+
if (this.needRestoreFocusIndex >= 0) {
60+
const viewItem = this.appointments.getViewItemBySortedIndex(
61+
this.needRestoreFocusIndex,
62+
);
63+
64+
viewItem?.setTabIndex(this.tabIndex);
65+
focus.trigger(viewItem?.$element());
66+
67+
this.focusableSortedIndex = this.needRestoreFocusIndex;
68+
this.needRestoreFocusIndex = -1;
69+
return;
70+
}
71+
72+
if (newFocusableIndex !== undefined) {
73+
this.appointments.getViewItemBySortedIndex(this.focusableSortedIndex)?.setTabIndex(-1);
74+
this.focusableSortedIndex = newFocusableIndex;
75+
}
76+
77+
// TODO: in virtual scrolling no appointment may be rendered in the initial viewport
78+
this.appointments
79+
.getViewItemBySortedIndex(this.focusableSortedIndex)
80+
?.setTabIndex(this.tabIndex);
81+
}
82+
83+
private handleTabKeyDown(e: KeyboardKeyDownEvent, sortedIndex: number): void {
84+
const nextIndex = sortedIndex + (e.shift ? -1 : 1);
85+
const nextItemData = this.sortedAppointments[nextIndex];
86+
87+
if (!nextItemData) {
88+
return;
89+
}
90+
91+
e.originalEvent.preventDefault();
92+
this.focusByItemData(nextItemData);
93+
}
94+
95+
private focusByItemData(itemData: SortedEntity): void {
96+
if (this.isVirtualScrolling) {
97+
this.scrollToItem(itemData);
98+
}
99+
100+
const viewItem = this.appointments.getViewItemBySortedIndex(itemData.sortedIndex);
101+
102+
if (viewItem) {
103+
this.focusViewItem(viewItem);
104+
} else if (this.isVirtualScrolling) {
105+
this.needRestoreFocusIndex = itemData.sortedIndex;
106+
}
107+
}
108+
109+
private focusViewItem(viewItem: ViewItem): void {
110+
this.resetTabIndex(viewItem.option().sortedIndex);
111+
focus.trigger(viewItem?.$element());
112+
}
113+
114+
private scrollToItem(itemData: SortedEntity): void {
115+
const { getStartViewDate, getResourceManager, scrollTo } = this.appointments.option();
116+
117+
const date = new Date(Math.max(
118+
getStartViewDate().getTime(),
119+
itemData.source.startDate,
120+
));
121+
122+
const group = getRawAppointmentGroupValues(
123+
itemData.itemData,
124+
getResourceManager().resources,
125+
);
126+
127+
scrollTo(date, { group, allDay: itemData.allDay });
128+
}
129+
}

0 commit comments

Comments
 (0)