@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
211 lines (210 loc) • 7.67 kB
JavaScript
/**
* @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved.
* For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options
*/
/**
* @module ui/list/listview
*/
import View from '../view.js';
import FocusCycler from '../focuscycler.js';
import ListItemView from './listitemview.js';
import ListItemGroupView from './listitemgroupview.js';
import ViewCollection from '../viewcollection.js';
import { FocusTracker, KeystrokeHandler } from '@ckeditor/ckeditor5-utils';
import '../../theme/components/list/list.css';
/**
* The list view class.
*/
export default class ListView extends View {
/**
* 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}.
*/
focusables;
/**
* Collection of the child list views.
*/
items;
/**
* Tracks information about DOM focus in the list.
*/
focusTracker;
/**
* Instance of the {@link module:utils/keystrokehandler~KeystrokeHandler}.
*/
keystrokes;
/**
* Helps cycling over focusable {@link #items} in the list.
*/
_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.
*/
_listItemGroupToChangeListeners = new WeakMap();
/**
* @inheritDoc
*/
constructor(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
*/
render() {
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('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
*/
destroy() {
super.destroy();
this.focusTracker.destroy();
this.keystrokes.destroy();
}
/**
* Focuses the first focusable in {@link #items}.
*/
focus() {
this._focusCycler.focusFirst();
}
/**
* Focuses the first focusable in {@link #items}.
*/
focusFirst() {
this._focusCycler.focusFirst();
}
/**
* Focuses the last focusable in {@link #items}.
*/
focusLast() {
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.
*/
_registerFocusableListItem(item, index) {
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.
*/
_deregisterFocusableListItem(item) {
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.
*/
_getOnGroupItemsChangeCallback(groupView) {
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.
*/
_registerFocusableItemsGroup(groupView, groupIndex) {
Array.from(groupView.items).forEach((child, childIndex) => {
const registeredChildIndex = typeof groupIndex !== 'undefined' ? groupIndex + childIndex : undefined;
this._registerFocusableListItem(child, 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('change', groupItemsChangeCallback);
}
/**
* Removes a list item group view (and its children) from the focus tracker.
*
* @param groupView The group view to be removed.
*/
_deregisterFocusableItemsGroup(groupView) {
for (const child of groupView.items) {
this._deregisterFocusableListItem(child);
}
groupView.items.off('change', this._listItemGroupToChangeListeners.get(groupView));
this._listItemGroupToChangeListeners.delete(groupView);
}
}