Skip to content

Commit

Permalink
Listboxes with Rearrangeable Options: Add keyboard commands for multi…
Browse files Browse the repository at this point in the history
…ple selection (pull #1344)

Resolves issue #919 by adding the following commands for selecting options:
- Shift + arrow keys
- Control + Shift + home and end
- Control + A, command-a on macOS
- Shift + Click

Adds regression tests for the commands.

Co-authored-by: Matt King <[email protected]>
Co-authored-by: Carolyn MacLeod <[email protected]>
  • Loading branch information
3 people authored Mar 31, 2020
1 parent 30aa268 commit f84febc
Show file tree
Hide file tree
Showing 5 changed files with 415 additions and 48 deletions.
1 change: 1 addition & 0 deletions examples/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ aria.KeyCode = {
BACKSPACE: 8,
TAB: 9,
RETURN: 13,
SHIFT: 16,
ESC: 27,
SPACE: 32,
PAGE_UP: 33,
Expand Down
3 changes: 1 addition & 2 deletions examples/listbox/js/listbox-rearrangeable.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
window.addEventListener('load', function () {
var ex1 = document.getElementById('ex1');
var ex1ImportantListbox = new aria.Listbox(document.getElementById('ss_imp_list'));
var ex1Toolbar = new aria.Toolbar(ex1.querySelector('[role="toolbar"]'));
var ex1UnimportantListbox = new aria.Listbox(document.getElementById('ss_unimp_list'));
new aria.Toolbar(ex1.querySelector('[role="toolbar"]'));

ex1ImportantListbox.enableMoveUpDown(
document.getElementById('ex1-up'),
Expand Down Expand Up @@ -44,7 +44,6 @@ window.addEventListener('load', function () {
});
ex1UnimportantListbox.setupMove(document.getElementById('ex1-add'), ex1ImportantListbox);

var ex2 = document.getElementById('ex2');
var ex2ImportantListbox = new aria.Listbox(document.getElementById('ms_imp_list'));
var ex2UnimportantListbox = new aria.Listbox(document.getElementById('ms_unimp_list'));

Expand Down
134 changes: 110 additions & 24 deletions examples/listbox/js/listbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ aria.Listbox = function (listboxNode) {
this.multiselectable = this.listboxNode.hasAttribute('aria-multiselectable');
this.moveUpDownEnabled = false;
this.siblingList = null;
this.startRangeIndex = 0;
this.upButton = null;
this.downButton = null;
this.moveButton = null;
Expand All @@ -39,6 +40,10 @@ aria.Listbox.prototype.registerEvents = function () {
this.listboxNode.addEventListener('focus', this.setupFocus.bind(this));
this.listboxNode.addEventListener('keydown', this.checkKeyPress.bind(this));
this.listboxNode.addEventListener('click', this.checkClickItem.bind(this));

if (this.multiselectable) {
this.listboxNode.addEventListener('mousedown', this.checkMouseDown.bind(this));
}
};

/**
Expand Down Expand Up @@ -86,10 +91,11 @@ aria.Listbox.prototype.focusLastItem = function () {
aria.Listbox.prototype.checkKeyPress = function (evt) {
var key = evt.which || evt.keyCode;
var lastActiveId = this.activeDescendant;
var firstItem = this.listboxNode.querySelector('[role="option"]');
var nextItem = document.getElementById(this.activeDescendant) || firstItem;
var allOptions = this.listboxNode.querySelectorAll('[role="option"]');
var currentItem = document.getElementById(this.activeDescendant) || allOptions[0];
var nextItem = currentItem;

if (!nextItem) {
if (!currentItem) {
return;
}

Expand All @@ -113,7 +119,7 @@ aria.Listbox.prototype.checkKeyPress = function (evt) {

if (!this.activeDescendant) {
// focus first option if no option was previously focused, and perform no other actions
this.focusItem(nextItem);
this.focusItem(currentItem);
break;
}

Expand All @@ -129,10 +135,14 @@ aria.Listbox.prototype.checkKeyPress = function (evt) {
}

if (key === aria.KeyCode.UP) {
nextItem = this.findPreviousOption(nextItem);
nextItem = this.findPreviousOption(currentItem);
}
else {
nextItem = this.findNextOption(nextItem);
nextItem = this.findNextOption(currentItem);
}

if (nextItem && this.multiselectable && event.shiftKey) {
this.selectRange(this.startRangeIndex, nextItem);
}

if (nextItem) {
Expand All @@ -144,10 +154,22 @@ aria.Listbox.prototype.checkKeyPress = function (evt) {
case aria.KeyCode.HOME:
evt.preventDefault();
this.focusFirstItem();

if (this.multiselectable && evt.shiftKey && evt.ctrlKey) {
this.selectRange(this.startRangeIndex, 0);
}
break;
case aria.KeyCode.END:
evt.preventDefault();
this.focusLastItem();

if (this.multiselectable && evt.shiftKey && evt.ctrlKey) {
var startItem = allOptions[this.startRangeIndex];
this.selectRange(this.startRangeIndex, allOptions.length - 1);
}
break;
case aria.KeyCode.SHIFT:
this.startRangeIndex = this.getElementIndex(currentItem, allOptions);
break;
case aria.KeyCode.SPACE:
evt.preventDefault();
Expand Down Expand Up @@ -196,6 +218,14 @@ aria.Listbox.prototype.checkKeyPress = function (evt) {
this.focusItem(nextUnselected);
}
break;
case 65:
// handle control + A
if (this.multiselectable && (evt.ctrlKey || evt.metaKey)) {
evt.preventDefault();
this.selectRange(0, allOptions.length - 1);
break;
}
// fall through
default:
var itemToFocus = this.findItemToFocus(key);
if (itemToFocus) {
Expand Down Expand Up @@ -239,6 +269,14 @@ aria.Listbox.prototype.findItemToFocus = function (key) {
return nextMatch;
};

/* Return the index of the passed element within the passed array, or null if not found */
aria.Listbox.prototype.getElementIndex = function (option, options) {
var allOptions = Array.prototype.slice.call(options); // convert to array
var optionIndex = allOptions.indexOf(option);

return typeof optionIndex === 'number' ? optionIndex : null;
};

/* Return the next listbox option, if it exists; otherwise, returns null */
aria.Listbox.prototype.findNextOption = function (currentOption) {
var allOptions = Array.prototype.slice.call(this.listboxNode.querySelectorAll('[role="option"]')); // get options array
Expand Down Expand Up @@ -296,10 +334,25 @@ aria.Listbox.prototype.findMatchInRange = function (list, startIndex, endIndex)
* The click event object
*/
aria.Listbox.prototype.checkClickItem = function (evt) {
if (evt.target.getAttribute('role') === 'option') {
this.focusItem(evt.target);
this.toggleSelectItem(evt.target);
this.updateScroll();
if (evt.target.getAttribute('role') !== 'option') {
return;
}

this.focusItem(evt.target);
this.toggleSelectItem(evt.target);
this.updateScroll();

if (this.multiselectable && evt.shiftKey) {
this.selectRange(this.startRangeIndex, evt.target);
}
};

/**
* Prevent text selection on shift + click for multiselect listboxes
*/
aria.Listbox.prototype.checkMouseDown = function (evt) {
if (this.multiselectable && evt.shiftKey && evt.target.getAttribute('role') === 'option') {
evt.preventDefault();
}
};

Expand All @@ -317,14 +370,7 @@ aria.Listbox.prototype.toggleSelectItem = function (element) {
element.getAttribute('aria-selected') === 'true' ? 'false' : 'true'
);

if (this.moveButton) {
if (this.listboxNode.querySelector('[aria-selected="true"]')) {
this.moveButton.setAttribute('aria-disabled', 'false');
}
else {
this.moveButton.setAttribute('aria-disabled', 'true');
}
}
this.updateMoveButton();
}
};

Expand Down Expand Up @@ -361,14 +407,57 @@ aria.Listbox.prototype.focusItem = function (element) {
this.listboxNode.setAttribute('aria-activedescendant', element.id);
this.activeDescendant = element.id;

if (!this.multiselectable && this.moveButton) {
this.moveButton.setAttribute('aria-disabled', false);
if (!this.multiselectable) {
this.updateMoveButton();
}

this.checkUpDownButtons();
this.handleFocusChange(element);
};

/**
* Helper function to check if a number is within a range; no side effects.
*/
aria.Listbox.prototype.checkInRange = function(index, start, end) {
var rangeStart = start < end ? start : end;
var rangeEnd = start < end ? end : start;

return index >= rangeStart && index <= rangeEnd;
}

/**
* Select a range of options
*/
aria.Listbox.prototype.selectRange = function (start, end) {
// get start/end indices
var allOptions = this.listboxNode.querySelectorAll('[role="option"]');
var startIndex = typeof start === 'number' ? start : this.getElementIndex(start, allOptions);
var endIndex = typeof end === 'number' ? end : this.getElementIndex(end, allOptions);

for (var index = 0; index < allOptions.length; index++) {
var selected = this.checkInRange(index, startIndex, endIndex);
allOptions[index].setAttribute('aria-selected', selected + '');
}

this.updateMoveButton();
};

/**
* Check for selected options and update moveButton, if applicable
*/
aria.Listbox.prototype.updateMoveButton = function() {
if (!this.moveButton) {
return;
}

if (this.listboxNode.querySelector('[aria-selected="true"]')) {
this.moveButton.setAttribute('aria-disabled', 'false');
}
else {
this.moveButton.setAttribute('aria-disabled', 'true');
}
}

/**
* Check if the selected option is in view, and scroll if not
*/
Expand Down Expand Up @@ -487,10 +576,7 @@ aria.Listbox.prototype.clearActiveDescendant = function () {
this.activeDescendant = null;
this.listboxNode.setAttribute('aria-activedescendant', null);

if (this.moveButton) {
this.moveButton.setAttribute('aria-disabled', 'true');
}

this.updateMoveButton();
this.checkUpDownButtons();
};

Expand Down
7 changes: 5 additions & 2 deletions examples/listbox/listbox-rearrangeable.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ <h3 id="ex2_label">Example 2: Multi-Select Listbox</h3>
<li id="ms_opt4" role="option" aria-selected="false">Rear seat warmers</li>
<li id="ms_opt5" role="option" aria-selected="false">Front sun roof</li>
<li id="ms_opt6" role="option" aria-selected="false">Rear sun roof</li>
<li id="ms_opt7" role="option" aria-selected="false">Privacy cloque</li>
<li id="ms_opt7" role="option" aria-selected="false">Cloaking capability</li>
<li id="ms_opt8" role="option" aria-selected="false">Food synthesizer</li>
<li id="ms_opt9" role="option" aria-selected="false">Advanced waste recycling system</li>
<li id="ms_opt10" role="option" aria-selected="false">Turbo vertical take-off capability</li>
Expand Down Expand Up @@ -290,7 +290,10 @@ <h4 id="kbd_label_multiselect">Multiple selection keys supported in example 2</h
<td>Selects from the focused option to the end of the list.</td>
</tr>
<tr data-test-id="key-control-a">
<th><kbd>Control + A</kbd></th>
<th>
<kbd>Control + A</kbd> (All Platforms)<br>
<kbd>Command-A</kbd> (macOS)
</th>
<td>selects all options in the list. If all options are selected, unselects all options.</td>
</tr>
</tbody>
Expand Down
Loading

0 comments on commit f84febc

Please sign in to comment.