@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
190 lines (189 loc) • 6.28 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
*/
import { View } from './view.js';
import '../theme/components/arialiveannouncer/arialiveannouncer.css';
/**
* The politeness level of an `aria-live` announcement.
*
* Available keys are:
* * `AriaLiveAnnouncerPoliteness.POLITE`,
* * `AriaLiveAnnouncerPoliteness.ASSERTIVE`
*
* [Learn more](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions#Politeness_levels).
*/
export const AriaLiveAnnouncerPoliteness = {
POLITE: 'polite',
ASSERTIVE: 'assertive'
};
/**
* An accessibility helper that manages all ARIA live regions associated with an editor instance. ARIA live regions announce changes
* to the state of the editor features.
*
* These announcements are consumed and propagated by screen readers and give users a better understanding of the current
* state of the editor.
*
* To announce a state change to an editor use the {@link #announce} method:
*
* ```ts
* editor.ui.ariaLiveAnnouncer.announce( 'Text of an announcement.' );
* ```
*/
export class AriaLiveAnnouncer {
/**
* The editor instance.
*/
editor;
/**
* The view that aggregates all `aria-live` regions.
*/
view;
/**
* @inheritDoc
*/
constructor(editor) {
this.editor = editor;
/**
* Some screen readers only look at changes in the aria-live region.
* They might not read a region that already has content when it is added.
* To stop this problem, make sure to set up regions for all politeness settings when the editor starts.
*/
editor.once('ready', () => {
for (const politeness of Object.values(AriaLiveAnnouncerPoliteness)) {
this.announce('', politeness);
}
});
}
/**
* Sets an announcement text to an aria region that is then announced by a screen reader to the user.
*
* If the aria region of a specified politeness does not exist, it will be created and can be re-used later.
*
* The default announcement politeness level is `'polite'`.
*
* ```ts
* // Most screen readers will queue announcements from multiple aria-live regions and read them out in the order they were emitted.
* editor.ui.ariaLiveAnnouncer.announce( 'Image uploaded.' );
* editor.ui.ariaLiveAnnouncer.announce( 'Connection lost. Reconnecting.' );
* ```
*/
announce(announcement, attributes = AriaLiveAnnouncerPoliteness.POLITE) {
const editor = this.editor;
if (!editor.ui.view) {
return;
}
if (!this.view) {
this.view = new AriaLiveAnnouncerView(editor.locale);
editor.ui.view.body.add(this.view);
}
const { politeness, isUnsafeHTML } = typeof attributes === 'string' ? {
politeness: attributes
} : attributes;
let politenessRegionView = this.view.regionViews.find(view => view.politeness === politeness);
if (!politenessRegionView) {
politenessRegionView = new AriaLiveAnnouncerRegionView(editor, politeness);
this.view.regionViews.add(politenessRegionView);
}
politenessRegionView.announce({
announcement,
isUnsafeHTML
});
}
}
/**
* The view that aggregates all `aria-live` regions.
*/
export class AriaLiveAnnouncerView extends View {
/**
* A collection of all views that represent individual `aria-live` regions.
*/
regionViews;
constructor(locale) {
super(locale);
this.regionViews = this.createCollection();
this.setTemplate({
tag: 'div',
attributes: {
class: [
'ck',
'ck-aria-live-announcer'
]
},
children: this.regionViews
});
}
}
/**
* The view that represents a single `aria-live`.
*/
export class AriaLiveAnnouncerRegionView extends View {
/**
* Current politeness level of the region.
*/
politeness;
/**
* DOM converter used to sanitize unsafe HTML passed to {@link #announce} method.
*/
_domConverter;
/**
* Interval used to remove additions. It prevents accumulation of added nodes in region.
*/
_pruneAnnouncementsInterval;
constructor(editor, politeness) {
super(editor.locale);
this.setTemplate({
tag: 'div',
attributes: {
'aria-live': politeness,
'aria-relevant': 'additions'
},
children: [
{
tag: 'ul',
attributes: {
class: [
'ck',
'ck-aria-live-region-list'
]
}
}
]
});
editor.on('destroy', () => {
if (this._pruneAnnouncementsInterval !== null) {
clearInterval(this._pruneAnnouncementsInterval);
this._pruneAnnouncementsInterval = null;
}
});
this.politeness = politeness;
this._domConverter = editor.data.htmlProcessor.domConverter;
this._pruneAnnouncementsInterval = setInterval(() => {
if (this.element && this._listElement.firstChild) {
this._listElement.firstChild.remove();
}
}, 5000);
}
/**
* Appends new announcement to region.
*/
announce({ announcement, isUnsafeHTML }) {
if (!announcement.trim().length) {
return;
}
const messageListItem = document.createElement('li');
if (isUnsafeHTML) {
this._domConverter.setContentOf(messageListItem, announcement);
}
else {
messageListItem.innerText = announcement;
}
this._listElement.appendChild(messageListItem);
}
/**
* Return current announcements list HTML element.
*/
get _listElement() {
return this.element.querySelector('ul');
}
}