DEV Community

Cover image for A11y: Vanilla javascript aria-live announcer

A11y: Vanilla javascript aria-live announcer

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 configurable aria-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);
    }
}
Enter fullscreen mode Exit fullscreen mode
  • This class would also have an announce method, that would be called with a message and a politeness 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)
    }
Enter fullscreen mode Exit fullscreen mode
  • Add a destroy method to cleanup the node from the DOM, reset the singleton and allow for reinitialization via an init 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;
    }
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • 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;
    }
Enter fullscreen mode Exit fullscreen mode

Consumption of AriaLiveAnnouncer

Consumption is very straightforward:

Install the package

npm install aria-announcer-js
Enter fullscreen mode Exit fullscreen mode

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!");
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
artydev profile image
artydev

Thank you, very useful, at least for me :-)

Collapse
 
get_pieces profile image
Pieces 🌟

Nice and useful package.

Collapse
 
namingthingsishard profile image
Shwetha

Neat little package !