Since I've worked with Angular & Angular Material for a few years, I got used to a lot of the nice features that it provides out of the box.
One of those features, that I didn't use a lot, but it was a useful one nonetheless was the LiveAnnouncer from Angular Material.
This provided a nice API to announce messages to the screen reader.
I found some use cases for this while working on non Angular projects, and I wanted to check if there was a vanilla javascript solution that would standardize the consumption of the aria-live attribute.
I found some react live announcers, but no package that would expose a vanilla javascript live announcer, so I decided to create my own (didn't do a thorough search since I was a bit interested to build one).
Concept of AriaLiveAnnouncer
I started with a simple idea:
- Expose a class called
AriaLiveAnnouncer
that when initialized, it would insert a singleton DOM element with a configurablearia-live
politeness setting.
type Politeness = 'off' | 'polite' | 'assertive'
const UNIQUE_ID = '__aria-announcer-element__';
const DEFAULT_POLITENESS = 'polite';
interface AriaLiveAnnouncerProps {
politeness?: Politeness;
}
export class AriaLiveAnnouncer {
static #instantiated = false;
static __DEBUG__ = false;
#rootElement;
constructor({ politeness }: AriaLiveAnnouncerProps = { politeness: DEFAULT_POLITENESS}) {
this.init({ politeness });
}
init({ politeness }: AriaLiveAnnouncerProps = { politeness: DEFAULT_POLITENESS }) {
if (AriaLiveAnnouncer.#instantiated) {
AriaLiveAnnouncer.__DEBUG__ && console.warn('AriaLiveAnnouncer is already instantiated');
return;
}
AriaLiveAnnouncer.#instantiated = true;
this.#politeness = politeness;
this.#rootElement = document.createElement('div');
this.#rootElement.id = UNIQUE_ID;
this.#rootElement.style.width = '0';
this.#rootElement.style.height = '0';
this.#rootElement.style.opacity = '0';
this.#rootElement.style.position= 'absolute';
this.#rootElement.setAttribute('aria-live', politeness);
document.body.appendChild(this.#rootElement);
}
}
- This class would also have an
announce
method, that would be called with amessage
and apoliteness
override. This will announce the message to the screen reader (by changing the content of the element), and then reset the content of the DOM node & the politeness to the initialized one.
// method that will post a message to the screen reader
announce(message: string, politeness: Politeness = this.#politeness) {
if (!this.#rootElement) {
AriaLiveAnnouncer.__DEBUG__ && console.warn('AriaLiveAnnouncer not initialized, please use init() method');
return;
}
// temporary change the politeness setting
this.#rootElement.setAttribute('aria-live', politeness);
this.#rootElement.innerText = message;
// cleanup the message and reset the politeness setting
setTimeout(() => {
this.#rootElement.innerText = null;
this.#rootElement.setAttribute('aria-live', this.#politeness)
}, 100)
}
- Add a
destroy
method to cleanup the node from the DOM, reset the singleton and allow for reinitialization via aninit
method exposed as well.
// Cleanup method that will remove the element and reset the singleton
destroy() {
document.body.removeChild(this.#rootElement);
this.#rootElement = undefined;
AriaLiveAnnouncer.#instantiated = false;
}
Simple enough, so I implemented it, but as soon as I started testing it I noticed some improvement opportunities:
- First of all, I had to decide on the delay between announcing and cleaning up the content. I initially went with a default, but decided to make it customizable from the consumer.
interface AriaLiveAnnouncerProps {
politeness?: Politeness;
processingTime?: number;
}
//...same as before
const DEFAULT_PROCESSING_TIME = 500;
//...
export class AriaLiveAnnouncer {
//...
#processingTime = DEFAULT_PROCESSING_TIME;
//...
constructor({ politeness, processingTime }: AriaLiveAnnouncerProps = { politeness: DEFAULT_POLITENESS, processingTime: DEFAULT_PROCESSING_TIME }) {
this.init({ politeness, processingTime });
}
// Init method to allow consecutive `destroy` and `init`.
init({ politeness, processingTime }: AriaLiveAnnouncerProps = { politeness: DEFAULT_POLITENESS, processingTime: DEFAULT_PROCESSING_TIME}) {
//...same as before
this.#processingTime = processingTime;
//...
}
announce(message: string, politeness: Politeness = this.#politeness) {
//...same as before but use `this.#politeness` for the timeout
}
}
- After that, I thought about what would happen if the
announce
method was called multiple times while the element was not yet cleaned up or announced. So for this, I added an announcement queue that would process each message based on the configured time, and simply add to this queue while it's processing existing items.
export class AriaLiveAnnouncer {
//...
#announcementQueue = [];
#isAnnouncing = false;
//...
// method that will post a message to the screen reader
announce(message: string, politeness: Politeness = this.#politeness) {
if (!this.#rootElement) {
AriaLiveAnnouncer.__DEBUG__ && console.warn('AriaLiveAnnouncer not initialized, please use init() method');
return;
}
this.#announcementQueue.push({ message, politeness });
if (!this.#isAnnouncing) {
this.#processQueue();
}
}
//...
// Recursive method to process the announced messages one at a time based on the processing time provided by the consumer or using the default
#processQueue() {
if (this.#announcementQueue.length > 0) {
this.#isAnnouncing = true;
const { message, politeness } = this.#announcementQueue.shift();
this.#rootElement.setAttribute('aria-live', politeness);
this.#rootElement.innerText = message;
setTimeout(() => {
if (!this.#rootElement) {
return;
}
this.#rootElement.innerText = '';
this.#rootElement.setAttribute('aria-live', this.#politeness);
this.#processQueue();
}, this.#processingTime);
} else {
this.#isAnnouncing = false;
}
}
}
- The problem now was if the
destroy
method was called while there was still items left in the queue. So for this, I gracefully joined the existing messages left in the queue (each on a new line) and announced them all at once, then cleaned up.
destroy() {
const remaining = this.#announcementQueue.length;
if (remaining > 0) {
AriaLiveAnnouncer.__DEBUG__ && console.warn(`Destroying AriaLiveAnnouncer with ${remaining} items left to announce. Announcing them all at once`);
this.#rootElement.setAttribute('aria-live', this.#politeness);
this.#rootElement.innerText = this.#announcementQueue.map(v => v.message).join('\n');
this.#clearQueue();
setTimeout(() => this.#cleanup(), this.#processingTime);
} else {
this.#cleanup();
}
}
#clearQueue() {
this.#announcementQueue = [];
this.#isAnnouncing = false;
}
// Private cleanup method that removes the element and resets the announcement queue & singleton
#cleanup() {
document.body.removeChild(this.#rootElement);
this.#rootElement = undefined;
this.#clearQueue();
AriaLiveAnnouncer.#instantiated = false;
}
Consumption of AriaLiveAnnouncer
Consumption is very straightforward:
Install the package
npm install aria-announcer-js
Consume it
import { AriaLiveAnnouncer } from 'aria-announcer-js';
// defaults are 'polite' and '500'
const announcer = new AriaLiveAnnouncer({ politeness: 'polite', processingTime: 500 });
// Announce a message
announcer.announce("Hello, world!");
And that's it ! It was a quick nice little project (maybe overengineered a bit) and I decided to share it as well if anybody has the need for something like this and does not want to implement it from scratch (you can find it on github and on npm).
Hope it's useful or at least a nice read !
Thank you!
Top comments (3)
Thank you, very useful, at least for me :-)
Nice and useful package.
Neat little package !