Code Monkey home page Code Monkey logo

vue-a11y-utils's Introduction

Vue A11y Utils

NPM Version Language Types LICENSE CircleCI

Utilities for accessibility (a11y) in Vue.js

Table of Contents

Why

When you want to write a Vue app with full accessibility. You may meet some issues frequently. For example:

  • Making sure the W3C WAI-ARIA roles & properties of each DOM element are set properly.
  • Controling the focus and finish every use case elegantly only through keyboard.
  • Using a central live region to read messages right now in a screen reader.
  • Sometimes you need set a ID reference or ID reference list type aria attribute with ID of another DOM element. But we don't use ID in Vue to identify a DOM element right?

Vue A11y Utils try to supply a group of utilities to help Vue developers finish these jobs easier. They are:

Getting Started

Install

npm install vue-a11y-utils

or

yarn add vue-a11y-utils

Import

// choose the utils below as you like
import {
  VueAria,
  directiveAria,
  MixinKeyTravel,
  MixinId,
  VueFocusTrap,
  MixinKeyShortcuts,
  VueLive
} from "vue-a11y-utils";

Usage

See the docs below or preview some examples online.

<VueAria> Component

This component helps you to write role and aria-* attributes likely in a better way.

First you could put all aria-* attributes in an JS object. Second these a11y attributes could be inherited when more than 1 <VueAria> components nested. Third, it's more portable to use.

Another thing important is the tabindex attribute which could make an element focusable. But sometimes when the role changed into "none" or "appearance", there should be a easy way to control whether it is focusable as well.

Examples

For Props role And aria

<template>
  <VueAria role="menubutton" :aria="aria">
    <button>WAI-ARIA Quick Links</button>
  </VueAria>
</template>

<script>
import { VueAria } from "vue-a11y-utils";
export default {
  components: { VueAria },
  data() {
    return {
      aria: {
        haspopup: true,
        controls: "menu2"
      }
    };
  }
};
</script>

which is same to:

<template>
  <button id="menubutton" aria-haspopup="true" aria-controls="menu2">
    WAI-ARIA Quick Links
  </button>
</template>

So the content and structure in template is more clear than which with a lot of aria-* attribute in.

The aria prop could also be an Array which is convenient to merge multiple aria-* attribute from different places:

<template>
  <VueAria
    role="menubutton"
    :aria="[
      ariaData,
      ariaProps,
      otherAriaFromSomewhereElse
    ]"
  >
      <button>WAI-ARIA Quick Links</button>
  </VueAria>
</template>

And this component could be nested like:

<template>
  <VueAria :aria="otherAriaFromSomewhereElse">
    <VueAria :aria="ariaProps">
      <VueAria role="menubutton" :aria="ariaData">
        <button>WAI-ARIA Quick Links</button>
      </VueAria>
    </VueAria>
  </VueAria>
</template>

or:

<template>
  <VueAria role="menubutton">
    <VueAria :aria="aria">
      <button>WAI-ARIA Quick Links</button>
    </VueAria>
  </VueAria>
</template>

For Prop tabindex

If you want to make a <div> focusable. You should give it a tabindex attribute. For example:

<template>
  <VueAria
    role="menubutton"
    :tabindex="0"
  >
    <div>WAI-ARIA Quick Links</div>
  </VueAria>
</template>

When you pass "none" or "appearance" value into role prop but without a tabindex prop. The tabindex attribute on the root element will finally be "" by default. For examples:

<template>
  <!-- won't be focused by click or TAB key -->
  <VueAria role="none">
    <div tabindex="0" role="menubutton">WAI-ARIA Quick Links</div>
  </VueAria>
</template>
<template>
  <!-- won't be focused TAB key but could be focused by click -->
  <VueAria role="none" :tabindex="-1">
    <div role="button" tabindex="0">WAI-ARIA Quick Links</div>
  </VueAria>
</template>

API

props

  • role: string
  • aria: Array or Object
  • tabindex: number

::: tip When you pass "none" or "appearance" value into role prop but without a tabindex prop. The tabindex attribute on the root element will finally be "" by default. :::

slots

  • default slot: the element you would put these a11y attributes on (only one root element is accepted)

v-aria Custom Directive

If you prefer using directives rather than components, here is another choise: v-aria custom directive.

It helps you to write aria-* attributes better throught a Vue custom directive.

Almost the same to the aria prop in <VueAria> component, let you put all aria-* attributes in an object or array.

::: tip Because the custom directive would modify the DOM element. It is different from component which renders virtual DOM. So v-aria will run after all <VueAria> executed if you put both of them on a same DOM element. And the performance of v-aria would be theoritically a little bit slower than <VueAria> if you use them quite a lot. :::

Examples

<template>
  <i class="icon-save" role="button" v-aria="aria" />
</template>

<script>
import { directiveAria } from "vue-a11y-utils";
export default {
  data() {
    return {
      aria: {
        label: "save your changes",
        controls: "id-of-a-textbox"
      }
    };
  },
  directives: {
    aria: directiveAria
  }
};
</script>

This example above is same to:

<template>
  <i
    class="icon-save"
    role="button"
    aria-label="save your changes"
    aria-controls="id-of-a-textbox"
  >
</template>

Btw. there is no custom directive such as v-role and v-tabindex because you can set the two raw attributes directly on the same component or element with v-aria.

KeyTravel Mixin

This mixin help you travel through focusable items by arrow keys in a Vue component. At the same time you could easily fire an action by enter key or space key.

Examples

Auto-focus

The first example is about auto-focus. Make sure where is a value (through a prop/data/computed etc.) named autofocus in the component. When it's truthy, the item returned by getAutofocusItem() would be focused when component mounted to the DOM.

<template>
  <div>
    <button ref="btn">Click!</button>
  </div>
</template>

<script>
export default {
  mixins: [MixinKeyTravel],
  data() {
    return {
      // You can also define this value through `prop` or `computed` etc.
      autofocus: true
    };
  },
  methods: {
    // The mixin will call this method to find the focus when mounted to the DOM.
    getAutofocusItem() {
      return this.$refs.btn;
    }
  }
};
</script>

Focus Travel Using Arrow Keys

The second example is about focus travel using arrow keys in a Vue component. There are 2 files:

  • App.vue:

    <template>
      <div role="list" @keydown="keyTravel">
        <ListItem
          ref="items"
          v-for="option in options"
          :key="option.value"
          :text="option.text"
          :value="option.value"
        />
      </div>
    </template>
    
    <script>
    export default {
      mixins: [MixinKeyTravel],
      components: { ListItem },
      data() {
        return {
          autofocus: true,
          // Only ArrowUp and ArrowDown keys would work.
          orientation: "vertical"
        };
      },
      props: {
        options: Array
      },
      methods: {
        // You need to define all focusable items here. And if you don't define
        // getAutofocusItem(), the first one you defined will be auto-focused.
        getKeyItems() {
          return this.$refs.items;
        }
      }
    };
    </script>
  • ListItem.vue:

    <template>
      <div role="listitem" tabindex="-1" @click="fireAction">{{ text }}</div>
    </template>
    
    <script>
    export default {
      props: {
        text: String,
        value: String
      },
      methods: {
        fireAction() {
          alert(this.value);
        }
      }
    };
    </script>

Here are some points you would notice:

  1. Bind @keydown="keyTravel" to the root DOM element of your component.
  2. Put a prop/data/computed named orientation to define which arrow keys would work.
  3. Define a getKeyItems() method to return all focusable items.
  4. Define a fireAction() method in <ListItem> for the action when user press enter or space.

Now you can use ArrowUp and ArrowDown keys to travel each items. When you press enter or space key, an alert with the value of the current focused item would be poped up.

API

Method you can call

  • keyTravel(event: KeyboardEvent, config?: KeyConfig): void

    The second parameter is optional. The key is the key in the keyboard event, and the value if the "travel signal" to trigger when user press the corresponding key.

    All available travel signals: prev, next, prevPage, nextPage, first, last, action.

    Default config:

    • ArrowUp: prev when this.orientation is vertical or empty
    • ArrowDown: next when this.orientation is vertical or empty
    • ArrowLeft: prev when this.orientation is horizontal or empty
    • ArrowRight: next when this.orientation is horizontal or empty
    • Home: first
    • End: last
    • Enter: action
    • Space: action

Values you can define

  • autofocus: boolean
  • orientation: 'horizontal' | 'vertical' | other

Methods you can override

Main method for travel:

  • getKeyItems(): Array<Vue | HTMLElement>: return an empty array by default

Main method for auto-focus:

  • getAutofocusItem(): void: return first key item by default

Methods you can customize to fire action:

  • fireAction(item: Vue | HTMLElement): void: call item.fireAction() by default

Methods you can customize to travel:

  • goPrev(): void: focus previous item
  • goNext(): void: focus next item
  • goFirst(): void: focus the first item
  • goLast(): void: focus the last item
  • goNextPage(): void: do nothing by default
  • goPrevPage(): void: do nothing by default
  • goAction(): void: fire action at the current focused item

Method you can define in item component

  • fireAction(): void

Id Mixin

In modern web framework today, the id attribute of an element is almost never used. But in WAI-ARIA, some aria-* attributes like aria-controls, aria-labelledby only accept id reference or id reference list. Another problem about id is that it's always global unique. But every Vue component has its own scope. It's not easy to make sure the id in this component wouldn't be used in other Vue components.

This mixin help you generate unique id (sometimes as an id prefix) for elements in a component by default. And you can also easily specify the id manually if necessary.

Examples

Generate unique id

input.vue:

<template>
  <div :id="localId">
    <label ref="label" :id="`${localId}-label`">Username</label>
    <input
      ref="input"
      :id="`${localId}-input`"
      :aria-labelledby="`${localId}-label`"
    />
  </div>
</template>

<script>
export default {
  mixins: [MixinId]
};
</script>

In this example, the localId is a data member which is generated by Id mixin. It's globally unique so you don't need worry about that.

If you have a form with a group of inputs, this example above will be suitable.

Use id passed from parent component

Think about you should bind a clear button out of the input component above. For this kind of cases, you can easily set an id prop to it from parent like this:

foo.vue:

<template>
  <div>
    <VueInput id="foo" />
    <button aria-controls="foo-input">Clear</button>
  </div>
</template>

<script>
import VueInput from "input.vue";
export default {
  mixins: [MixinId],
  components: { VueInput }
};
</script>

Now the final generated DOM tree is:

<div>
  <div id="foo">
    <label id="foo-label">Username</label>
    <input id="foo-input" aria-labelledby="foo-label" />
  </div>
  <button aria-controls="foo-input">Clear</button>
</div>

API

Props you can use

  • id: string

Values you can get

  • localId: string

<VueFocusTrap> Component

Usually, when there is a modal dialog in your Vue app, you should keep the focus still in this dialog whatever you navigate with touch, mouse or keyboard.

<VueFocusTrap> gives you a easy way to trap focus by just two events gofirst and golast which should bind handlers to reset the focus to the first or last focusable target in the dialog. It also has a disabled prop to stop trapping focus which could be set true when the dialog is hidden or disabled.

Examples

In this example below, after you open the modal dialog by click the trigger button, the focus will always in the 4 control elements in <form>, whatever you press tab, tab + shift or click somewhere out of the dialog:

<template>
  <div>
    <button ref="trigger" @click="shown = true">
      Open a Modal Dialog
    </button>
    <form class="dialog" v-show="shown">
      <VueFocusTrap :disabled="!shown" @gofirst="goFirst" @golast="goLast">
        <label>Email: <input ref="email" type="email" /></label>
        <label>Password: <input ref="password" type="password" /></label>
        <button ref="login" @click="shown = false">Login</button>
        <button ref="cancel">Cancel</button>
      </VueFocusTrap>
    </form>
  </div>
</template>

<script>
export default {
  components: { VueFocusTrap },
  data() {
    return { shown: false };
  },
  watch: {
    shown(value) {
      if (value) {
        this.$nextTick(() => this.goFirst());
      } else {
        this.$nextTick(() => this.goTrigger());
      }
    }
  },
  methods: {
    goFirst() {
      this.$refs.email.focus();
    },
    goLast() {
      this.$refs.cancel.focus();
    },
    goTrigger() {
      this.$refs.trigger.focus();
    }
  }
};
</script>

::: tip Additionally, as a best practise of managing focus, you'd better auto-focus the first control element in when the dialog shows up, and auto-focus the trigger button back when the dialog closed. Just like the code logic in the example above. :::

API

Props

  • disabled: boolean

Slots

  • default slot: the content you would trap focus in.

Events

  • gofirst: when you should manually set focus to the first focusable element
  • golast: when you should manually set focus to the last focusable element

Using <VueFocusTrap> Component and KeyTravel Mixin Together

The better thing is: you can combine <VueFocusTrap> component and KeyTravel mixin together in a widget like actionsheet.

<template>
  <div>
    <button ref="trigger" @click="shown = true">
      Open a Modal Dialog
    </button>
    <ul class="actionsheet" v-show="shown" @keydown="keyTravel">
      <VueFocusTrap @gofirst="goFirst" @golast="goLast">
        <li
          v-for="option in options"
          :key="option.value"
          ref="items"
          tabindex="0"
        >{{ option.text }}</li>
      </VueFocusTrap>
    </ul>
  </div>
</template>

<script>
export default {
  mixins: [MixinKeyTravel],
  components: { VueFocusTrap },
  props: { options: Array, value: String },
  data() { return { shown: false, orientation: 'vertical' }; },
  watch: {
    shown(value) {
      if (value) {
        this.$nextTick(() => this.getAutofocusItem().focus());
      } else {
        this.$nextTick(() => this.goTrigger());
      }
    }
  },
  methods: {
    getKeyItems() { return this.$refs.items; },
    getAutofocusItem() {
      const items = this.getKeyItems();
      const index = this.options.map(option => option.value).indexOf(value);
      return items[index] || items[0];
    },
    goTrigger() { this.$refs.trigger.focus(); },
    fireAction(item) {
      const items = this.getKeyItems();
      const index = this.options.map(option => option.value).indexOf(value);
      const currentIndex = items.indexOf(item);
      if (index !== currentIndex) {
        const option = this.options[index];
        if (option) {
          this.$emit('input', .value);
        }
      }
      this.shown = false;
    }
  }
};
</script>

KeyShortcuts Mixin

Examples

Listen CMD + G:

<template>...</template>

<script>
export default {
  mixins: [MixinKeyShortcuts],
  shortcuts: [
    {
      key: "G",
      modifiers: { meta: true },
      handle(event) {
        alert("trigger: CMD + G");
      }
    }
  ]
};
</script>

Another way to config CMD + G as a keys sequence:

<template>...</template>

<script>
export default {
  mixins: [MixinKeyShortcuts],
  shortcuts: [
    {
      keys: [(key: "G"), (modifiers: { meta: true })],
      handle(event) {
        alert("trigger: CMD + G");
      }
    }
  ]
};
</script>

You can also quickly config each key in keys as a string if there is no modifiers to declare:

<template>...</template>

<script>
export default {
  mixins: [MixinKeyShortcuts],
  shortcuts: [
    {
      keys: ["a", "s", "d", "f"],
      handle(event) {
        alert("trigger: A-S-D-F");
      }
    }
  ]
};
</script>

At last, if you would like to bind key shortcuts on a certain element, for example an input text box, we also supports named shortcuts like below:

<template>
  <div>
    <input
      type="text" value="CMD + G"
      @keydown="bindShortcut($event, 'foo')"
    />
    <input
      type="text" value="CMD + K"
      @keydown="bindShortcut($event, 'bar')"
    />
  </div>
</template>

<script>
export default {
  mixins: [MixinKeyShortcuts],
  shortcuts: {
    foo: [
      {
        key: "g",
        modifiers: { meta: true },
        handle(event) {
          alert("trigger: CMD + G");
        }
      }
    ],
    bar: [
      {
        key: "k",
        modifiers: { meta: true },
        handle(event) {
          alert("trigger: CMD + K");
        }
      }
    ]
  }
};
</script>

API

New option you can define

  • shortcuts: Array<ShortcutConfig>

  • shortcuts: Record<string, ShortcutConfig>

  • shortcuts: Record<string, Array<ShortcutConfig>>

    The interface ShortcutConfig is like:

    {
      key: string,
      modifiers: {
        ctrl?: boolean,
        shift?: boolean,
        alt?: boolean, // you can also use `option`
        meta?: boolean // you can also use `cmd` or `window`
      },
      handle(event: KeyboardEvent)
    } |
    {
      keys[
        {
          key: string,
          modifiers: {
            ctrl?: boolean,
            shift?: boolean,
            alt?: boolean, // you can also use `option`
            meta?: boolean // you can also use `cmd` or `window`
          }
        } |
        key: string
      ],
      handle(event: KeyboardEvent)
    }

Methods you can use

  • bindShortcut(event: KeyboardEvent, name: string)

<VueLive> Component

inspired from react-aria-live by AlmeroSteyn

This component is actually a wrapper which generates a invisible WAI-ARIA live region and provides a default slot which injects some methods to announce live messages on its descendant components.

Examples

App.vue:

<template>
  <VueLive>
    <Foo />
  </VueLive>
</template>

<script>
export default {
  components: { VueLive }
};
</script>

Foo.vue:

<template>
  <div>
    Message: <input type="text" v-model="message" />
    <button @click="announce(message)">Announce</button>
  </div>
</template>

<script>
export default {
  inject: ["announce"],
  data() {
    return { message: "" };
  }
};
</script>

Now, if you enable VoiceOver of other a11y screen readers, there will be a live message when you input something in the textbox and press the announce button.

The injected method announce(message) could announce live message to the screen reader.

But by default the live message will be announced "politely" after other voices have been spoken. If you want to announce the message immediately, you can add a second parameter with a truthy value:

<template>
  <div>
    Message: <input type="text" v-model="message" />
    <input type="checkbox" v-model="immediately" />: immediately
    <button @click="announce(message, immediately)">Announce</button>
  </div>
</template>

<script>
export default {
  inject: ["announce"],
  data() {
    return {
      message: "",
      immediately: false
    };
  }
};
</script>

Also there is a third boolean parameter which could announce the same message by force if the current message is same to the previous one.

As the example below, you can choose the way by two parameters: immediately and force. And another injected method could manually clear the message history. That is another way to ensure the same message could be announced.

<template>
  <div>
    Message: <input type="text" v-model="message" />
    <input type="checkbox" v-model="immediately" />: immediately
    <input type="checkbox" v-model="force" />: force
    <button @click="announce(message, immediately, force)">Announce</button>
    <button @click="clear()">Clear</button>
  </div>
</template>

<script>
export default {
  inject: ["announce", "clear"],
  data() {
    return {
      message: "",
      immediately: false,
      force: false
    };
  }
};
</script>

API

Props

  • role: string: "log" by default, you can also choose other live region roles
  • label: string: the label of the live region

Slots

  • default slot: the content you would wrap.

Provide

  • announce(message: string, immediately: boolean, force: boolean): announce message to screen reader
    • message: the message text would be announced
    • immediately: whether announce immediately or "politely"
    • force: whether announce by force whatever the message is same to the previous one
  • clear(): clear the previous message history to ensure the next message 100% would be announced
  • isBusy(busy: boolean) if you set it true, only the last message you send during that time would be announced after you set it false later (experimental, not sure screen readers support that well)

vue-a11y-utils's People

Contributors

jinjiang avatar

Stargazers

 avatar

Watchers

 avatar  avatar  avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.