A11y: Vanilla javascript aria-live announcer
Tags: a11y, arialive, javascript, vanilla
Published: 2024-02-18 08:41:49

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](https://material.angular.io/cdk/a11y/overview#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](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) 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.
```typescript
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 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.
```typescript
    // 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 an `init` method exposed as well.
```typescript
    // 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.
```typescript
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.
```typescript
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.
```typescript
    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
```bash
npm install aria-announcer-js
```

Consume it
```typescript
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](https://github.com/CalinaCristian/aria-announcer-js/tree/main) and on [npm](https://www.npmjs.com/package/aria-announcer-js)).

Hope it's useful or at least a nice read !
Thank you!