Would you like to buy me a ☕️ instead?
This is the second article in a two-part series on how to build accessible, custom form select components with Vue.js. You can read about the first technique, which is more of a concept, in my previous article.
Today, we will follow the W3C guidelines, on how to build a collapsible dropdown, very closely, to create a solid custom form select Vue.js component that works well for both keyboard and screen reader users as well as people who use a mouse or their finger to browse the web.
A more advanced accessible custom select component
Although the simple approach works quite well if you display only a few options, it is not ideal if you have to provide the user with dozens of options. In such cases, the best solution is to use the native <select>
element. If for some reason this is not possible, the second best solution is to use a custom component which behaves exactly like a regular <select>
element.
We want our custom component to come as close as possible to a native form element. This means that it should behave exactly like a normal <select>
element when using either a mouse or a finger or the keyboard or a screenreader to navigate through the page.
<template>
<div
class="FormSelect"
@blur.capture="handleBlur"
>
<span :id="`${_uid}-label`">
{{ label }}
</span>
<div class="FormSelect__control">
<button
ref="button"
:id="`${_uid}-button`"
aria-haspopup="listbox"
:aria-labelledby="`${_uid}-label ${_uid}-button`"
:aria-expanded="optionsVisible"
class="FormSelect__button"
@click="toggleOptions"
@keyup.up.down.prevent="showOptions"
@keyup.up.prevent="selectPrevOption"
@keyup.down.prevent="selectNextOption"
>
{{ value }}
<span v-if="!value" class="FormSelect__placeholder">
{{ placeholder }}
</span>
<SvgAngle
class="FormSelect__icon"
:class="{ 'FormSelect__icon--rotate-180': optionsVisible }"
/>
</button>
<ul
v-show="optionsVisible"
ref="options"
tabindex="-1"
role="listbox"
:aria-labelledby="`${_uid}-label`"
:aria-activedescendant="activeDescendant"
class="FormSelect__options"
@focus="setupFocus"
@keyup.up.prevent="selectPrevOption"
@keyup.down.prevent="selectNextOption"
@keydown.up.down.prevent
@keydown.enter.esc.prevent="reset"
>
<li
v-for="(option, index) in options"
:key="option.label || option"
:id="`${_uid}-option-${index}`"
:aria-selected="activeOptionIndex === index"
:class="activeOptionIndex === index && 'has-focus'"
class="FormSelect__option"
role="option"
@click="handleOptionClick(option)"
>
{{ option.label || option }}
</li>
</ul>
</div>
</div>
</template>
<script>
import SvgAngle from './SvgAngle.vue';
export default {
name: 'FormSelect',
components: {
SvgAngle,
},
model: {
event: 'change',
},
props: {
label: {
type: String,
required: true,
},
placeholder: {
type: String,
default: 'Select',
},
options: {
type: Array,
default: () => [],
},
value: {
type: [String, Number],
default: '',
},
},
data() {
return {
optionsVisible: false,
};
},
computed: {
activeOptionIndex() {
return this.options.findIndex(
x => x.value === this.value || x === this.value
);
},
prevOptionIndex() {
const next = this.activeOptionIndex - 1;
return next >= 0 ? next : this.options.length - 1;
},
nextOptionIndex() {
const next = this.activeOptionIndex + 1;
return next <= this.options.length - 1 ? next : 0;
},
activeDescendant() {
return `${this._uid}-option-${this.activeOptionIndex}`;
},
},
methods: {
handleOptionClick(option) {
this.$emit('change', option);
this.reset();
},
handleBlur(e) {
if (this.$el.contains(e.relatedTarget)) return;
this.hideOptions();
},
toggleOptions() {
this.optionsVisible ? this.hideOptions() : this.showOptions();
},
async showOptions() {
this.optionsVisible = true;
await this.$nextTick();
this.$refs.options.focus();
},
hideOptions() {
this.optionsVisible = false;
},
async reset() {
this.hideOptions();
await this.$nextTick();
this.$refs.button.focus();
},
setupFocus() {
if (this.value) return;
this.$emit('change', this.options[0]);
},
selectPrevOption() {
this.$emit('change', this.options[this.prevOptionIndex]);
},
selectNextOption() {
this.$emit('change', this.options[this.nextOptionIndex]);
},
},
};
</script>
<style lang="scss">
@import '../assets/theme';
.FormSelect {
&__control {
@include form-control();
position: relative;
padding: 0;
}
&__button {
width: 100%;
display: flex;
justify-content: space-between;
padding: $form-control-padding;
background-color: transparent;
border: none;
outline: none;
}
&__placeholder {
color: $placeholder-color;
}
&__icon {
transition: transform 0.2s;
&--rotate-180 {
transform: rotate(180deg);
}
}
&__options {
margin: 0;
padding: 0;
list-style-type: none;
outline: none;
}
&__option {
padding: $form-control-padding;
cursor: default;
&.has-focus {
background-color: rgba(#80bdff, 0.25);
}
}
}
</style>
Here you can see the code necessary to handle the basic functionality of a custom select field. We have to bind a lot of event listeners in order to deal with a ton of different ways how to control the dropdown with the keyboard. This solution already behaves very similar to a native form element, but there are two exceptions. First, in iOS it is not possible to jump to this field with the little arrows on the keyboard. Second, it is not possible to focus the custom selection component and press a key on the keyboard to jump to the first option that starts with the entered letters.
iOS tab behavior
Let’s take a look at what we can do about the iOS arrow problem. The only way I found around this is to add an invisible <input>
element to trap the focus. As soon as this element gets focus it triggers the dropdown to open.
<template>
<div
class="FormSelect"
+ @keydown.tab="tabKeyPressed = true"
@blur.capture="handleBlur"
>
<span :id="`${_uid}-label`">
If we detect a real tab keypress (which means the user is not using the arrows on the iOS keyboard), we remove the focus trap because we don’t need it in this case.
:class="{ 'FormSelect__icon--rotate-180': optionsVisible }"
/>
</button>
+ <!-- Focus trap for iOS keyboard navigation. -->
+ <input
+ v-if="!tabKeyPressed"
+ aria-hidden="true"
+ class="u-visually-hidden"
+ @focus="handleFocusTrap"
+ >
<ul
v-show="optionsVisible"
ref="options"
},
data() {
return {
+ tabKeyPressed: false,
optionsVisible: false,
};
},
}
},
methods: {
+ handleFocusTrap(e) {
+ this.optionsVisible = true;
+ this.$refs.button.focus();
+ },
handleOptionClick(option) {
this.$emit('change', option);
this.reset();
As you can see in the following video, now we can detect if an iOS user is using the little arrows on the keyboard to navigate through the form and we can open the dropdown as soon as we trap the focus. Although it kinda works, this solution is far from ideal because the keyboard disappears and it doesn’t feel exactly like with a regular <select>
field.
Do you want to learn more about advanced Vue.js techniques?
Register for the Newsletter of my upcoming book: Advanced Vue.js Application Architecture.
Select option based on first letter
A lesser-known feature of native <select>
elements is that you can type the initial letters of an option to immediately select the first option that matches your input. This is especially important for very long lists of options. Let’s take a look at how we can replicate this functionality in our custom component.
@focus="setupFocus"
@keyup.up.prevent="selectPrevOption"
@keyup.down.prevent="selectNextOption"
+ @keydown="search"
@keydown.up.down.prevent
@keydown.enter.esc.prevent="reset"
>
First, we bind a keydown
event onto our options <ul>
list in order to detect if the user uses the keyboard to search for an option.
<script>
import SvgAngle from './SvgAngle.vue';
+let resetKeysSoFarTimer;
export default {
name: 'FormSelect',
},
data() {
return {
+ keysSoFar: '',
tabKeyPressed: false,
optionsVisible: false,
};
},
selectNextOption() {
this.$emit('change', this.options[this.nextOptionIndex]);
},
+ search(e) {
+ clearTimeout(resetKeysSoFarTimer);
+ // No alphanumeric key was pressed.
+ if (e.key.length > 1) return;
+
+ resetKeysSoFarTimer = setTimeout(() => {
+ this.keysSoFar = '';
+ }, 500);
+
+ this.keysSoFar += e.key;
+ const matchingOption = this.options.find(x =>
+ (x.value || x).toLowerCase().startsWith(this.keysSoFar)
+ );
+
+ if (!matchingOption) return;
+
+ this.$emit('change', matchingOption);
+ },
},
};
</script>
Here you can see how we handle user keyboard input while the options list has the focus. As long as the user is typing, we clear the resetKeysSoFarTimer
so the user can search for the first option that starts with a sequence of letters.
Wrapping it up
Making a custom form input component work exactly like their native counterparts is almost impossible to do. At least if our goal ist to target all common browsers and platforms. I myself have decided to use regular HTML form field elements wherever possible. But if you have to use a custom built solution, always try to make it work as good as possible for keyboard and screen reader users.
If you only have to make some minor tweaks to the looks of the select element I recommend you to read the following article by Scott Jehl: Styling a Select Like It’s 2019.