9
votes

I am trying to get a vue component to announce information dynamically to a screen reader when different events occur on my site.

I have it working to where clicking a button will populate a span that is aria-live="assertive" and role="alert" with text. This works decently the first time, however, clicking other buttons with similar behavior causes NVDA to read the previous text twice before reading the new text. This seems to be happening in vue, but not with a similar setup using jquery, so I'm guessing it has something to do with the way vue renders to the DOM.

I'm hoping there is some way to workaround this problem or perhaps a better way to read the text to the user that would not have this issue. Any help is greatly appreciated.

Here is a simple component I set up in a working code sandbox to show the problem I am having (navigate to components/HelloWorld.vue for the code) -- Note: This sandbox has changed per the answer below. Full code for the component is below:

export default {
  name: "HelloWorld",
  data() {
    return {
      ariaText: ""
    };
  },
  methods: {
    button1() {
      this.ariaText = "This is a bunch of cool text to read to screen readers.";
    },
    button2() {
      this.ariaText = "This is more cool text to read to screen readers.";
    },
    button3() {
      this.ariaText = "This text is not cool.";
    }
  }
};
<template>
  <div>
    <button @click="button1">1</button>
    <button @click="button2">2</button>
    <button @click="button3">3</button><br/>
    <span role="alert" aria-live="assertive">{{ariaText}}</span>
  </div>
</template>
1

1 Answers

2
votes

Ok so what I've found works way more consistently is instead of replacing the text in the element with new text, to add a new element to a parent container with the new text to be read. Instead of storing the text as a single string, I am storing it in an array of strings which will v-for onto the page within an aria-live container.

I have built a full component that will do this in various ways as an example for anyone looking to do the same:

export default {
    props: {
        value: String,
        ariaLive: {
            type: String,
            default: "assertive",
            validator: value => {
                return ['assertive', 'polite', 'off'].indexOf(value) !== -1;
            }
        }
    },
    data() {
        return {
            textToRead: []
        }
    },
    methods: {
        say(text) {
            if(text) {
                this.textToRead.push(text);
            }
        }
    },
    mounted() {
        this.say(this.value);
    },
    watch: {
        value(val) {
            this.say(val);
        }
    }
}
.assistive-text {
    position: absolute;
    margin: -1px;
    border: 0;
    padding: 0;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
}
<template>
    <div class="assistive-text" :aria-live="ariaLive" aria-relevant="additions">
        <slot></slot>
        <div v-for="(text, index) in textToRead" :key="index">{{text}}</div>
    </div>
</template>

This can be used by setting a variable on the parent to the v-model of the component, and any changes to that variable will be read to a screen reader once (as well as any time the parent container becomes tab-focused).

It can also be triggered by this.$refs.component.say(textToSay); -- note this will also be triggered again if the parent container becomes tab-focused. This behavior can be avoided by putting the element within a container that will not receive focus.

It also includes a slot so text can be added like this: <assistive-text>Text to speak</assistive-text> however, that should not be a dynamic/mustache variable or you will encounter the problem in the original question when the text changes.

I've also updated the sandbox posted in the question with a working example of this component.