/
listview.ts
280 lines (235 loc) · 8.06 KB
/
listview.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
/**
* @license Copyright (c) 2003-2024, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
*/
/**
* @module ui/list/listview
*/
import View from '../view.js';
import FocusCycler, { type FocusableView } from '../focuscycler.js';
import ListItemView from './listitemview.js';
import ListItemGroupView from './listitemgroupview.js';
import type ListSeparatorView from './listseparatorview.js';
import type DropdownPanelFocusable from '../dropdown/dropdownpanelfocusable.js';
import ViewCollection from '../viewcollection.js';
import {
FocusTracker,
KeystrokeHandler,
type Locale,
type GetCallback,
type CollectionChangeEvent
} from '@ckeditor/ckeditor5-utils';
import '../../theme/components/list/list.css';
/**
* The list view class.
*/
export default class ListView extends View<HTMLUListElement> implements DropdownPanelFocusable {
/**
* The collection of focusable views in the list. It is used to determine accessible navigation
* between the {@link module:ui/list/listitemview~ListItemView list items} and
* {@link module:ui/list/listitemgroupview~ListItemGroupView list groups}.
*/
public readonly focusables: ViewCollection<FocusableView>;
/**
* Collection of the child list views.
*/
public readonly items: ViewCollection<ListItemView | ListItemGroupView | ListSeparatorView>;
/**
* Tracks information about DOM focus in the list.
*/
public readonly focusTracker: FocusTracker;
/**
* Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
public readonly keystrokes: KeystrokeHandler;
/**
* Label used by assistive technologies to describe this list element.
*
* @observable
*/
declare public ariaLabel: string | undefined;
/**
* (Optional) The ARIA property reflected by the `aria-ariaLabelledBy` DOM attribute used by assistive technologies.
*
* @observable
*/
declare public ariaLabelledBy?: string | undefined;
/**
* The property reflected by the `role` DOM attribute to be used by assistive technologies.
*
* @observable
*/
declare public role: string | undefined;
/**
* Helps cycling over focusable {@link #items} in the list.
*/
private readonly _focusCycler: FocusCycler;
/**
* A cached map of {@link module:ui/list/listitemgroupview~ListItemGroupView} to `change` event listeners for their `items`.
* Used for accessibility and keyboard navigation purposes.
*/
private readonly _listItemGroupToChangeListeners: WeakMap<ListItemGroupView, GetCallback<ListItemsChangeEvent>> = new WeakMap();
/**
* @inheritDoc
*/
constructor( locale?: Locale ) {
super( locale );
const bind = this.bindTemplate;
this.focusables = new ViewCollection();
this.items = this.createCollection();
this.focusTracker = new FocusTracker();
this.keystrokes = new KeystrokeHandler();
this._focusCycler = new FocusCycler( {
focusables: this.focusables,
focusTracker: this.focusTracker,
keystrokeHandler: this.keystrokes,
actions: {
// Navigate list items backwards using the arrowup key.
focusPrevious: 'arrowup',
// Navigate toolbar items forwards using the arrowdown key.
focusNext: 'arrowdown'
}
} );
this.set( 'ariaLabel', undefined );
this.set( 'ariaLabelledBy', undefined );
this.set( 'role', undefined );
this.setTemplate( {
tag: 'ul',
attributes: {
class: [
'ck',
'ck-reset',
'ck-list'
],
role: bind.to( 'role' ),
'aria-label': bind.to( 'ariaLabel' ),
'aria-labelledby': bind.to( 'ariaLabelledBy' )
},
children: this.items
} );
}
/**
* @inheritDoc
*/
public override render(): void {
super.render();
// Items added before rendering should be known to the #focusTracker.
for ( const item of this.items ) {
if ( item instanceof ListItemGroupView ) {
this._registerFocusableItemsGroup( item );
} else if ( item instanceof ListItemView ) {
this._registerFocusableListItem( item );
}
}
this.items.on<ListItemsChangeEvent>( 'change', ( evt, data ) => {
for ( const removed of data.removed ) {
if ( removed instanceof ListItemGroupView ) {
this._deregisterFocusableItemsGroup( removed );
} else if ( removed instanceof ListItemView ) {
this._deregisterFocusableListItem( removed );
}
}
for ( const added of Array.from( data.added ).reverse() ) {
if ( added instanceof ListItemGroupView ) {
this._registerFocusableItemsGroup( added, data.index );
} else {
this._registerFocusableListItem( added, data.index );
}
}
} );
// Start listening for the keystrokes coming from #element.
this.keystrokes.listenTo( this.element! );
}
/**
* @inheritDoc
*/
public override destroy(): void {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Focuses the first focusable in {@link #items}.
*/
public focus(): void {
this._focusCycler.focusFirst();
}
/**
* Focuses the first focusable in {@link #items}.
*/
public focusFirst(): void {
this._focusCycler.focusFirst();
}
/**
* Focuses the last focusable in {@link #items}.
*/
public focusLast(): void {
this._focusCycler.focusLast();
}
/**
* Registers a list item view in the focus tracker.
*
* @param item The list item view to be registered.
* @param index Index of the list item view in the {@link #items} collection. If not specified, the item will be added at the end.
*/
private _registerFocusableListItem( item: ListItemView, index?: number ) {
this.focusTracker.add( item.element! );
this.focusables.add( item, index );
}
/**
* Removes a list item view from the focus tracker.
*
* @param item The list item view to be removed.
*/
private _deregisterFocusableListItem( item: ListItemView ) {
this.focusTracker.remove( item.element! );
this.focusables.remove( item );
}
/**
* Gets a callback that will be called when the `items` collection of a {@link module:ui/list/listitemgroupview~ListItemGroupView}
* change.
*
* @param groupView The group view for which the callback will be created.
* @returns The callback function to be used for the items `change` event listener in a group.
*/
private _getOnGroupItemsChangeCallback( groupView: ListItemGroupView ): GetCallback<ListItemsChangeEvent> {
return ( evt, data ) => {
for ( const removed of data.removed ) {
this._deregisterFocusableListItem( removed );
}
for ( const added of Array.from( data.added ).reverse() ) {
this._registerFocusableListItem( added, this.items.getIndex( groupView ) + data.index );
}
};
}
/**
* Registers a list item group view (and its children) in the focus tracker.
*
* @param groupView A group view to be registered.
* @param groupIndex Index of the group view in the {@link #items} collection. If not specified, the group will be added at the end.
*/
private _registerFocusableItemsGroup( groupView: ListItemGroupView, groupIndex?: number ) {
Array.from( groupView.items ).forEach( ( child, childIndex ) => {
const registeredChildIndex = typeof groupIndex !== 'undefined' ? groupIndex + childIndex : undefined;
this._registerFocusableListItem( child as ListItemView, registeredChildIndex );
} );
const groupItemsChangeCallback = this._getOnGroupItemsChangeCallback( groupView );
// Cache the reference to the callback in case the group is removed (see _deregisterFocusableItemsGroup()).
this._listItemGroupToChangeListeners.set( groupView, groupItemsChangeCallback );
groupView.items.on<ListItemsChangeEvent>( 'change', groupItemsChangeCallback );
}
/**
* Removes a list item group view (and its children) from the focus tracker.
*
* @param groupView The group view to be removed.
*/
private _deregisterFocusableItemsGroup( groupView: ListItemGroupView ) {
for ( const child of groupView.items ) {
this._deregisterFocusableListItem( child as ListItemView );
}
groupView.items.off( 'change', this._listItemGroupToChangeListeners.get( groupView )! );
this._listItemGroupToChangeListeners.delete( groupView );
}
}
// There's no support for nested groups yet.
type ListItemsChangeEvent = CollectionChangeEvent<ListItemView>;