Dual Listbox
- Dual listbox is a set of two listboxes that allows users to select multiple options and move the selection from one listbox to another.
- A descriptive label MUST be provided for the dual listbox.
- There SHOULD be a clear visual focus indication provided for the dual listbox.
NOTE:
- New to accessibility or uncertain of requirements, it will be helpful to review all sections below.
- Already familiar with requirements, skip to the “Working Example” section for sample HTML, CSS and JavaScript (when needed), along with a working demo.
- The color contrast requirement of 4.5:1 ratio MUST be met with the adjacent color for the label, options and button standard text for default, hover and focus states.
- The color contrast requirement of 3:1 ratio MUST be met with the adjacent color for the label, options and button large text (18 pt or 14 pt and bold) for default, hover and focus states.
- The contrast requirement of 3:1 ratio MUST be met with the adjacent colors for the custom focus indicator of the dual listbox and the buttons.
- Color alone SHOULD NOT be used as the only means to identify selected options.
-
Dual listbox MUST be defined using
role="listbox"
on the<ul>
container. - A descriptive visual label MUST be provided for the dual listbox. See “Label placement and structure” component for more information on labelling.
- In case of any additional instructions to be provided for dual listbox see “Label placement and structure” component for more information.
- For more information on additional requirements for form fields, see “Input Structure” component.
-
The
aria-activedescendant
attribute MUST be provided to the<ul>
element containing therole="listbox"
. It refers to that option in the listbox that currently has visual focus. It helps assistive technologies know which element has application focus when the DOM focus is on the input field. For more information, refer to Managing Focus in Composites Using aria-activedescendant . -
The
<ul>
element havingrole="listbox"
MUST be providedtabindex="0"
attribute to bring the listbox in focus. - Appropriate JavaScript event handlers MUST be used to make the dual listbox accessible by keyboard and mouse.
Defining options for dual listbox
- Appropriate JavaScript event handlers SHOULD be used to make the options accessible by keyboard and mouse.
-
Each of the individual
<li>
element containing the options SHOULD be marked withrole="option"
. -
The
aria-selected="true"
SHOULD be used when an option is visually selected in the dual listbox. -
The
aria-multiselectable="true"
attribute SHOULD be used for the<ul>
element withrole="listbox"
. - Roving tabindex mechanism SHOULD be used for traversing between different options. For more information, refer to Managing Focus Within Components Using a Roving tabindex .
-
A visual indication of selection must be conveyed via an icon like a tick mark.
-
This icon should not be defined using CSS
background-image
property such that it is also visible in High Contrast Mode. - It SHOULD NOT be announced by assistive technologies and must adhere to color contrast requirement of 3:1 with the background in default, focused and hover state.
-
This icon should not be defined using CSS
Defining buttons to perform swapping of options between the dual listbox
- Buttons such as “Add” and “Remove” SHOULD be provided for moving the options from one listbox to another.
- Buttons such as “Remove all” and “Add all” CAN be provided for removing or adding all the options from one listbox to another
-
The buttons such as “Add”, “Remove” , “Remove all” and “Add all” MUST be defined using
<button>
element. - A descriptive and programmatic label MUST be provided for the buttons using a visible inner text.
-
If an image is used to identify the buttons, then a textual description MUST be defined
for the image buttons.
-
If the image is defined using
<img>
element, use an alt attribute with descriptive value. - If the image is defined using
<svg>
element, userole="img"
andaria-label
attribute to provide a role and an accessible name for the element.
Note: Providing ARIA based role and attribute on SVG image ensures robust support across different environments.
-
If the image is defined using
- Appropriate JavaScript event handlers SHOULD be used to make the buttons accessible by keyboard and mouse.
- When these buttons are activated, the selected options are moved from one listbox to another.
-
This information MUST be made available for screen reader users using
aria-live="polite"
along witharia-atomic="true"
attribute orrole="status"
on the neutral container in which this feedback can be structured.
Defining Search functionality for dual listbox
-
The search functionality CAN be implemented by defining an input field using
<input>
element. - Ensure that appropriate label is programmatically provided for the search input field. See “Label placement and structure” component for more information.
- Ensure that appropriate instructions regarding providing keyword to filter options if any in the listbox below is provided for all users. See “Label placement and structure” component for more information.
Focus & Keyboard Management
When the focus is on the multiselect box, focus SHOULD be managed as follows:
- Down arrow key – moves visual focus to the next option. In case of last option, focus remains on the last option.
- Up arrow key – moves visual focus to the previous option. In case of first option, focus remains on the first option.
- Space/Enter keys – selects the option that has visual focus currently.
- Shift + Up/Shift+ Down arrow keys (optional) – mechanism to select multiple options at one go CAN be provided.
- Pressing Home key moves focus to the first option.
- Pressing End key moves focus to the last option.
for example,
<!-- Defining Search Functionality -->
<!--suppress XmlDuplicatedId, XmlDuplicatedId -->
<label for="available-options-search">Search Language:</label>
<span class="visuallyhidden" id="input_instruction">
Entering keyword in search option, will update the available options below
</span>
<input type="text" id="available-options-search" aria-describedby="input_instruction" class="search" placeholder="Search" style="margin-bottom: 0.8em">
<!-- Defining first multiselect lisbox -->
<span id="avail_option">Available Language</span>
<ul id="available-options" tabindex="0" role="listbox" aria-multiselectable="true" aria-labelledby="avail_option" aria-activedescendant="available-options-search-0">
<li role="option" id="available-options-search-0" class="item option-current option-selected" aria-selected="true">HTML & CSS</li>
<li role="option" id="available-options-search-1" class="item" aria-selected="false">Bootstrap</li>
...
</ul>
<!-- Defining buttons to Add and Remove the selected options -->
<div>
<button id="add-button" aria-label="Add Language">Add</button>
<button id="remove-button" aria-label="Remove Language">Remove</button>
</div>
<!-- Defining second multiselect listbox -->
<div>
<span id="select_option">Selected Language</span>
<ul id="selected-options" tabindex="0" role="listbox" aria-multiselectable="true" aria-labelledby="select_option">
</ul>
</div>
<!-- Defining off-screen live region to communicate the addition and deletion of option from one listbox to another -->
<!-- Default code -->
<div class="visuallyhidden">
Last change: <span aria-live="polite" id="ms_live_region"></span>
</div>
<!-- When feedback is generated -->
<div class="visuallyhidden">
Last change: <span aria-live="polite" id="ms_live_region">Added 1 language</span>
</div>
-
Dual listbox works as expected in the combination of browsers and assistive technology as listed below.
-
Windows 10
- Chrome/JAWS 2022
- Firefox/NVDA 2023.1
- EDGE/JAWS 2022
-
MAC OS
- Safari/VoiceOver
-
Android
- Chrome/TalkBack
-
Windows 10
-
Dual listbox doesn’t work as expected in iOS – Safari using VoiceOver.
- Search functionality works as expected.
- The options can be selected from the first listbox.
- The selected options can be added to the second listbox.
- The added options in the second listbox cannot be interacted such that the options can be removed.
A well-defined dual listbox benefits majorly the below users.
- People with cognitive disabilities
- People using speech input
- People with limited dexterity
- People using keyboard only
- People using screen readers
<div class="dual-listbox">
<div class="listbox-area available">
<label for="available-options-search">Search Language:</label>
<span class="visuallyhidden" id="input_instruction">Entering keyword in search option, will update the available options below</span>
<input type="text" id="available-options-search" aria-describedby="input_instruction" class="search" placeholder="Search" style="margin-bottom: 0.8em">
<div>
<span id="avail_option">Available Language</span>
<ul id="available-options" tabindex="0" role="listbox" aria-multiselectable="true" aria-labelledby="avail_option">
</ul>
</div>
</div>
<div class="mgtButtons">
<button id="add-button" aria-label="Add Language">Add</button>
<button id="remove-button" aria-label="Remove Language">Remove</button>
</div>
<div class="listbox-area selected" style="margin-top: 1.7em">
<div>
<span id="select_option">Selected Language</span>
<ul id="selected-options" tabindex="0" role="listbox" aria-multiselectable="true" aria-labelledby="select_option">
</ul>
</div>
</div>
<div class="visuallyhidden">
Last change: <span aria-live="polite" id="ms_live_region"></span>
</div>
</div>
.listbox-area {
padding: 1rem;
}
[role="listbox"] {
margin: 1em 0 0;
padding: 0;
min-height: 18em;
border: 1px solid #aaa;
background: white;
}
[role="listbox"]#list-items {
position: relative;
overflow-y: auto;
}
[role="listbox"] + *,
.listbox-label + * {
margin-top: 1em;
}
[role="option"] {
position: relative;
display: block;
padding: 0 1em 0 1.5em;
line-height: 1.8em;
}
[role="option"].focused {
background: #bde4ff;
}
[role="option"][aria-selected="true"]::before {
position: absolute;
left: 0.5em;
content: "✓";
}
.option-current {
background-color: #007a9c !important;
color: #fff;
}
.dual-listbox {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.dual-listbox button {
font-size: 1.2rem;
display: block;
margin: 0.5rem;
padding: 0.5rem 1rem;
border: none;
background-color: #003057;
color: #fff;
cursor: pointer;
width: 100%;
}
ul {
width: 18rem;
height: 14rem;
}
.visuallyhidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(1px 1px 1px 1px);
clip: rect(1px, 1px, 1px, 1px);
font-size: 14px;
white-space: nowrap;
}
button:focus {
outline: .15em solid #495EB4;
outline-offset: 1px;
}
#available-options-search {
width: 10rem;
}
#mgtButtons {
padding: 1rem;
}
options = ['HTML & CSS', 'Bootstrap', 'Javascript', 'Node JS', 'React Js', 'Mongo DB'];
const options1 = [];
const Keys = {
Backspace: 'Backspace',
Clear: 'Clear',
Down: 'ArrowDown',
End: 'End',
Enter: 'Enter',
Escape: 'Escape',
Home: 'Home',
Left: 'ArrowLeft',
PageDown: 'PageDown',
PageUp: 'PageUp',
Right: 'ArrowRight',
Space: ' ',
Tab: 'Tab',
Up: 'ArrowUp'
}
const MenuActions = {
Close: 0,
CloseSelect: 1,
First: 2,
Last: 3,
Next: 4,
Open: 5,
Previous: 6,
Select: 7,
Space: 8,
Type: 9
}
function filterOptions(options = [], filter, exclude = []) {
return options.filter((option) => {
const matches = option.toLowerCase().includes(filter.toLowerCase());
return matches && exclude.indexOf(option) < 0;
});
}
function getActionFromKey(key) {
if (key === Keys.Down) {
return MenuActions.Next;
} else if (key === Keys.Up) {
return MenuActions.Previous;
} else if (key === Keys.Home) {
return MenuActions.First;
} else if (key === Keys.End) {
return MenuActions.Last;
} else if (key === Keys.Escape) {
return MenuActions.Close;
} else if (key === Keys.Enter) {
return MenuActions.CloseSelect;
} else if (key === Keys.Backspace || key === Keys.Clear || key.length === 1) {
return MenuActions.Type;
}
}
function getUpdatedIndex(current, max, action) {
switch(action) {
case MenuActions.First:
return 0;
case MenuActions.Last:
return max;
case MenuActions.Previous:
return Math.max(0, current - 1);
case MenuActions.Next:
return Math.min(max, current + 1);
default:
return current;
}
}
const DuallistBox = function(el, options) {
this.el = el;
this.inputbox = el.querySelector('input');
this.listbox = el.querySelector('#available-options[role=listbox]');
this.idBase = this.inputbox.id;
this.selectedEl = document.getElementById('selected-options');
this.options = options;
this.filteredOptions = options;
this.activeIndex = 0;
this.open = false;
}
const DuallistBox1 = function(el, options) {
this.el = el;
this.listbox1 = el.querySelector('#selected-options[role=listbox]');
this.idBase = 'selected-option';
this.selectedEl = document.getElementById('available-options');
this.options = options;
this.filteredOptions1 = options;
this.activeIndex = 0;
}
DuallistBox1.prototype.init = function() {
this.listbox1.addEventListener('keydown', this.onUlKeyDown.bind(this));
const ullist = this.listbox1.querySelectorAll('li');
const removeButton = document.getElementById('remove-button');
removeButton.addEventListener('click', this.appendavailable.bind(this));
}
DuallistBox.prototype.init = function() {
this.inputbox.addEventListener('input', this.onInput.bind(this));
this.listbox.addEventListener('keydown', this.onInputKeyDown.bind(this));
this.initcreateli(this.option);
const addButton = document.getElementById('add-button');
addButton.addEventListener('click', this.appendselected.bind(this));
}
DuallistBox.prototype.initcreateli = function(options) {
this.filteredOptions = this.options;
this.listbox.innerHTML = '';
this.options.map((option, index) => {
const optionali = document.createElement('li');
optionali.setAttribute('role', 'option');
optionali.id = `${this.idBase}-${index}`;
optionali.className = index === 0 ? 'item option-current' : 'item';
optionali.setAttribute('aria-selected', 'false');
optionali.innerText = option;
optionali.addEventListener('click', () => { this.onOptionClick(index); });
this.listbox.appendChild(optionali);
});
}
DuallistBox.prototype.filterOptions = function(value) {
this.filteredOptions = filterOptions(this.options, value);
const options = this.el.querySelectorAll('#available-options li');
[...options].forEach((optionali) => {
const value = optionali.innerText;
if (this.filteredOptions.indexOf(value) > -1) {
optionali.style.display = 'block';
} else {
optionali.style.display = 'none';
}
});
}
DuallistBox.prototype.onInput = function() {
const curValue = this.inputbox.value;
this.filterOptions(curValue);
// if active option is not in filtered options, set it to first filtered option
if (this.filteredOptions.indexOf(this.options[this.activeIndex]) < 0) {
const firstFilteredIndex = this.options.indexOf(this.filteredOptions[0]);
this.onOptionChange(firstFilteredIndex);
}
}
DuallistBox.prototype.onInputKeyDown = function(event) {
const { key } = event;
const max = this.filteredOptions.length - 1;
const activeFilteredIndex = this.filteredOptions.indexOf(this.options[this.activeIndex]);
const action = getActionFromKey(key);
switch(action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
const nextFilteredIndex = getUpdatedIndex(activeFilteredIndex, max, action);
const nextRealIndex = this.options.indexOf(this.filteredOptions[nextFilteredIndex]);
return this.onOptionChange(nextRealIndex);
case MenuActions.CloseSelect:
event.preventDefault();
return this.updateOption(this.activeIndex);
}
}
DuallistBox1.prototype.onUlKeyDown = function(event) {
const { key } = event;
const max = this.filteredOptions1.length - 1;
const activeFilteredIndex = this.filteredOptions1.indexOf(this.options[this.activeIndex]);
const action = getActionFromKey(key);
switch(action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
const nextFilteredIndex = getUpdatedIndex(activeFilteredIndex, max, action);
const nextRealIndex = this.options.indexOf(this.filteredOptions1[nextFilteredIndex]);
return this.onOptionChange1(nextRealIndex);
case MenuActions.CloseSelect:
event.preventDefault();
return this.updateOption1(this.activeIndex);
}
}
DuallistBox.prototype.onOptionChange = function(index) {
this.activeIndex = index;
this.listbox.setAttribute('aria-activedescendant', `${this.idBase}-${index}`);
// update active style
const options = this.el.querySelectorAll('#available-options li');
[...options].forEach((optionali) => {
optionali.classList.remove('option-current');
});
options[index].classList.add('option-current');
event.preventDefault();
}
DuallistBox1.prototype.onOptionChange1 = function(index) {
this.activeIndex = index;
this.listbox1.setAttribute('aria-activedescendant', `${this.idBase}-${index}`);
// update active style
const options = this.el.querySelectorAll('#selected-options li');
[...options].forEach((optionali) => {
optionali.classList.remove('option-current');
});
options[index].classList.add('option-current');
event.preventDefault();
}
DuallistBox.prototype.onOptionClick = function(index) {
this.onOptionChange(index);
this.updateOption(index);
this.listbox.focus();
}
DuallistBox1.prototype.onOptionClick1 = function(index) {
this.onOptionChange1(index);
this.updateOption1(index);
this.listbox1.focus();
}
DuallistBox.prototype.removeOption = function(index) {
const option = this.options[index];
// update aria-selected
const options = this.el.querySelectorAll('#available-options li');
options[index].setAttribute('aria-selected', 'false');
options[index].classList.remove('option-selected');
this.listbox.focus();
}
DuallistBox1.prototype.removeOption1 = function(index) {
const option = this.options[index];
// update aria-selected
const options = this.el.querySelectorAll('#selected-options li');
options[index].setAttribute('aria-selected', 'false');
options[index].classList.remove('option-selected');
this.listbox1.focus();
}
DuallistBox.prototype.selectOption = function(index) {
const selected = this.options[index];
this.activeIndex = index;
// update aria-selected
const options = this.el.querySelectorAll('#available-options li');
options[index].setAttribute('aria-selected', 'true');
options[index].classList.add('option-selected');
}
DuallistBox.prototype.appendselected = function() {
let index=this.el.querySelectorAll('#selected-options li').length;
let checkindex=this.el.querySelectorAll('#selected-options li').length;
let inlineButtonComponent2 = new DuallistBox1(this.el, options1);
let selectedlist = '';
const appendoptions = this.el.querySelectorAll('#available-options li.option-selected');
liveregion = appendoptions.length;
liveregionalert = this.el.querySelector('.visuallyhidden span')
liveregionalert.innerHTML = 'Added '+liveregion+' language';
setTimeout(() => {
liveregionalert.innerHTML = '';
}, "5000");
let numbarr = [];
appendoptions.forEach(optionlist => {
optionlistid = optionlist.id;
const selectli = document.createElement('li');
selectli.setAttribute('role', 'option');
selectli.id = 'selected-option-'+index;
selectli.setAttribute('data-index', index);
selectli.className = index === 0 ? 'item option-current' : 'item';
selectli.setAttribute('aria-selected', 'false');
selectli.innerHTML = optionlist.innerText;
selectedlist = optionlist.innerText;
this.selectedEl.appendChild(selectli).addEventListener('click', (e) => {
let id = e.target.getAttribute('data-index');
inlineButtonComponent2.onOptionClick1(id); });
let idnumber = optionlistid.match(/\d+/)[0];
numbarr.push(idnumber);
index++;
if (!options1.includes(selectedlist)) {
options1.push(selectedlist);
}
});
for (var i = numbarr.length -1; i >= 0; i--)
options.splice(numbarr[i],1);
let inlineButtonComponent1 = new DuallistBox(this.el, options);
this.initcreateli(options);
if (checkindex == 0 && eventbinded == 0) {
inlineButtonComponent2 = new DuallistBox1(this.el, options1);
inlineButtonComponent2.init();
eventbinded = 1;
}
};
DuallistBox1.prototype.appendavailable = function() {
index=0;
let selectedlist = '';
const selectedoptions = this.el.querySelectorAll('#selected-options li.option-selected');
liveregion = selectedoptions.length;
liveregionalert = this.el.querySelector('.visuallyhidden span')
liveregionalert.innerHTML = 'removed '+liveregion+' language';
setTimeout(() => {
liveregionalert.innerHTML = '';
}, "5000");
let numbarr = [];
selectedoptions.forEach(optionlist => {
optionlistid = optionlist.id;
optionlist.remove();
const selectli = document.createElement('li');
selectli.setAttribute('role', 'option');
selectli.id = optionlistid;
selectli.className = index === 0 ? 'item option-current' : 'item';
selectli.setAttribute('aria-selected', 'false');
selectli.innerHTML = optionlist.innerText;
selectedlist = optionlist.innerText;
this.selectedEl.appendChild(selectli);
const idnumber = optionlistid.match(/\d+/)[0];
numbarr.push(idnumber);
index++;
if (!options.includes(selectedlist)) {
options.push(selectedlist);
}
});
let availoptindex = 0;
let availopt = this.el.querySelectorAll('#selected-options li');
availopt.forEach((li, index) => {
li.id = 'selected-option-'+availoptindex;
li.setAttribute('data-index',availoptindex);
availoptindex++;
});
for (var i = numbarr.length -1; i >= 0; i--)
options1.splice(numbarr[i],1);
let inlineButtonComponent3 = new DuallistBox(this.el, options);
inlineButtonComponent3.initcreateli(options);
let inlineButtonComponent2 = new DuallistBox1(this.el, options1);
};
DuallistBox1.prototype.selectOption1 = function(index) {
const selected = this.options[index];
this.activeIndex = index;
const optionsselected = this.el.querySelectorAll('#selected-options li');
optionsselected[index].setAttribute('aria-selected', 'true');
optionsselected[index].classList.add('option-selected');
}
DuallistBox.prototype.updateOption = function(index) {
const option = this.options[index];
const optionalis = this.el.querySelectorAll('#available-options li');
const optionali = optionalis[index];
const isSelected = optionali.getAttribute('aria-selected') === 'true';
if (isSelected) {
this.removeOption(index);
} else {
this.selectOption(index);
}
}
DuallistBox1.prototype.updateOption1 = function(index) {
const option = this.options[index];
const optionalis = this.el.querySelectorAll('#selected-options li');
const optionali = optionalis[index];
const isSelected = optionali.getAttribute('aria-selected') === 'true';
if (isSelected) {
this.removeOption1(index);
} else {
this.selectOption1(index);
}
}
eventbinded = 0;
const inlinebuttonli = document.querySelector('.dual-listbox');
const inlineButtonComponent = new DuallistBox(inlinebuttonli, options);
inlineButtonComponent.init();
Entering keyword in search option, will update the available options below
Available Language
Selected Language
Last change: