VueSelect
HTML provides a variety of input elements. <select>
allows users to select one of many options. Accessibility tools like screen readers readily recognize these “native” input elements. However the downside of these elements is they are not fully customizable across browsers and platforms. Not being able to customize will be a problem if one is working a site that follows a specific design language like Material Design. One way around this limitation would be implement your own widget. This blog post is a walk-through of implementing such a widget as a Vue.js component which is accessible as well. Inspiration for this component MDN’s guide to implementing a custom widget.
MVP
Before we begin let us define our Minimum Viable Product for our component.
- Behaves similar to
<select>
- No dependencies apart from Vue.js
- Keyboard accessible
- Screen reader accessible
Enough talk, show me the code! Code for this component can be found on github at https://github.com/cx0der/vueselect and a simple demo can be found here.
HTML structure
The basic skeleton structure for our widget is very simple.
<div> <!-- Container --> | |
<span></span> <!-- Selected value --> | |
<ul> | |
<li></li> <!-- Options --> | |
<li></li> | |
<li></li> | |
</ul> | |
</div> |
The outer <div>
acts as the container of the component which houses a <span>
and a <ul>
. We will use the <span>
to show the selected option and use the <ul>
to show the options.
Styling the component
Now that we have our HTML structure in place, let us start customizing our component. For this I am using BEM naming convention and SCSS.
<style lang="scss" scoped> | |
$background-color: #fff; | |
$border-color: #101010; | |
$border-inactive-color: #606060; | |
$border-radius: 5px; | |
$item-hover-color: #f5f5f5; | |
$item-selected-color: rgba(0, 0, 0, .25); | |
*, | |
*:before, | |
*:after { | |
-webkit-box-sizing: border-box; | |
-moz-box-sizing: border-box; | |
box-sizing: border-box; | |
margin: 0; | |
padding: 0; | |
-webkit-user-select: none; | |
-moz-user-select: none; | |
-ms-user-select: none; | |
user-select: none; | |
} | |
.select__dropdown { | |
font-size: 16px; | |
outline: none; | |
position: relative; | |
text-align: left; | |
} | |
.select__dropdown--close { | |
&::after { | |
border-left: 6px solid transparent; | |
border-right: 6px solid transparent; | |
border-top: 6px solid $border-color; | |
content: ''; | |
height: 0; | |
position: absolute; | |
right: 8px; | |
top: 20px; | |
width: 0; | |
} | |
} | |
.select__dropdown--open { | |
&::after { | |
border-left: 6px solid transparent; | |
border-right: 6px solid transparent; | |
border-bottom: 6px solid $border-color; | |
content: ''; | |
height: 0; | |
position: absolute; | |
right: 8px; | |
top: 20px; | |
width: 0; | |
} | |
} | |
.select__value { | |
border: 1px solid $border-inactive-color; | |
border-radius: $border-radius; | |
display: block; | |
height: 48px; | |
outline: none; | |
padding: 16px; | |
&:focus { | |
border-color: $border-color; | |
} | |
} | |
.select__optionlist { | |
background-color: $background-color; | |
border: 1px solid $border-color; | |
border-radius: 0 0 $border-radius $border-radius; | |
list-style: none; | |
margin: 0; | |
max-height: 150px; | |
padding: 0; | |
padding-bottom: 8px; | |
padding-top: 8px; | |
position: relative; | |
top: -3px; | |
z-index: 2000; | |
} | |
.select__optionlist--close { | |
visibility: hidden; | |
} | |
.select__option { | |
height: 32px; | |
line-height: 2; | |
padding: 0 16px; | |
text-align: left; | |
vertical-align: middle; | |
&:hover { | |
background-color: $item-hover-color; | |
} | |
} | |
.select__option--selected { | |
background-color: $item-selected-color; | |
&:hover { | |
background-color: $item-selected-color; | |
} | |
} | |
</style> |
First thing to note is the scoped
attribute of the style tag. Vue.js takes care of keeping the styles defined by this component are local to this and does not leak out and conflict with others. One of the first things that we do is define some variables and reset the browser defaults for margin, padding, box-sizing
, and user-select
. Now we start defining CSS classes that we will for our component.
First class that we define is .select__dropdown
. This will be assigned to our container <div>
. We remove the outline
set the position
attribute to relative
, this will allows us to position the list of options. Next we define the chevron for our drop down using the pseudo-selector :after
using the classes .select__dropdown--open
, and .select__dropdown--close
. CSS-Tricks has a nice article on how to create triangles. Next we will define the styling for the <span>
using the class .select__value
. When our component is in focus we want to provide visual feedback, this is accomplished via the pseudo-selector :focus
. .select__optionlist
defines the styling for the list container <ul>
. We set the z-index
to a high value so that it overlaps the down arrow. Moving further we define the closed state of the drop down list using .select__optionlist--close
where we set the visibility
to hidden
. We are using the attribute visibility instead of display
is to support accessibility. To style the individual options we will define the class .select__option
. We use the :hover
pseudo-selector to provide visual feedback for mouse hover. When a certain option is selected we hightlight that using .select__option--selected
.
Now our HTML should look like this.
<div class="select__dropdown> | |
<span class="select__value"></span> | |
<ul class="select__optionlist"> | |
<li class="select__option">Option 1</li> | |
<li class="select__option">Option 2</li> | |
<li class="select__option">Option 3</li> | |
</ul> | |
</div> |
Making it all work
Mouse and keyboard events that we have to handle to make our component to be functional are:
onClick
of the container<div>
onClick
of the optionsSpace, Enter, Up arrow
, andDown arrow
keyboard events on the container<div>
We also have to add Accessibility attributes to our markup. These attributes enables screen readers to interpret VueSelect component correctly. Component after adding the event listeners and accessibility attributes should look something like this.
<div | |
:aria-expanded="[isOpen ? 'true' : 'false']" | |
:aria-owns="'lbox_' + _uid" | |
:class="['select__dropdown', isOpen ? 'select__dropdown--open' : 'select__dropdown--close']" | |
@click="toggle" | |
@keyup.space="toggle" | |
@keyup.up="moveUp" | |
@keyup.down="moveDown" | |
@keyup.enter="selectFromKeyboard" | |
aria-autocomplete="none" | |
role="combobox" | |
tabindex="-1"> | |
<span | |
class="select__value" | |
tabindex="0">{{ mutableValue }}</span> | |
<ul | |
:id="'lbox_' + _uid" | |
:class="['select__optionlist', isOpen ? '' : 'select__optionlist--close']" | |
role="listbox"> | |
<li | |
v-for="(opt, idx) in items" | |
:aria-selected="[isItemSelected(idx) ? 'true' : 'false']" | |
:class="['select__option', isItemSelected(idx) ? 'select__option--selected': '']" | |
:key="idx" | |
role="option" | |
@click="select(idx)">{{ opt }}</li> | |
</ul> | |
</div> |
Properties
VueSelect supports two properties, value
of type String
and items
and array of Strings
. As the name suggests, value
is the property that the parent component will pass to us with the preselected value or an empty string and items
are the options that the user can select from. Whenever user selects an option we will emit two events input
and update:value
. Custom event input
is straight forward to understand. Second event update:value
is emitted to support the new Vue 2.3.0+ feature the .sync modifier.
Component State
To orchestrate our component we need these data properties:
isOpen
— denotes if the drop down is open or closed.mutableValue
— the current selected value.selectedIdx
— index within theitems
property array.hoverIndex
— maintains the state of option on which the user is “hovering” when navigating via the keyboard.
Completed code
The completed code for this component can be found on Github at https://github.com/cx0der/vueselect. Note that this component is not production ready, this is something I wrote to understand how to implement accessible elements in Vue.