+ Select-Only Combobox : A single-select combobox with no text input that is functionally similar to an HTML select
element.
Editable Combobox with Both List and Inline Autocomplete : An editable combobox that demonstrates the autocomplete behavior known as list with inline autocomplete .
Editable Combobox Without Autocomplete : An editable combobox that demonstrates the behavior associated with aria-autocomplete=none
.
Editable Combobox with Grid Popup : An editable combobox that presents suggestions in a grid, enabling users to navigate descriptive information about each suggestion.
diff --git a/examples/combobox/combobox-autocomplete-none.html b/examples/combobox/combobox-autocomplete-none.html
index 35d05c1420..4ff2577dd2 100644
--- a/examples/combobox/combobox-autocomplete-none.html
+++ b/examples/combobox/combobox-autocomplete-none.html
@@ -36,6 +36,7 @@ Editable Combobox without Autocomplete Example
Similar examples include:
+ Select-Only Combobox : A single-select combobox with no text input that is functionally similar to an HTML select
element.
Editable Combobox with Both List and Inline Autocomplete : An editable combobox that demonstrates the autocomplete behavior known as list with inline autocomplete .
Editable Combobox with List Autocomplete : An editable combobox that demonstrates the autocomplete behavior known as list with manual selection .
Editable Combobox with Grid Popup : An editable combobox that presents suggestions in a grid, enabling users to navigate descriptive information about each suggestion.
diff --git a/examples/combobox/combobox-select-only.html b/examples/combobox/combobox-select-only.html
new file mode 100644
index 0000000000..64fe2b9337
--- /dev/null
+++ b/examples/combobox/combobox-select-only.html
@@ -0,0 +1,417 @@
+
+
+
+
+Select-Only Combobox Example | WAI-ARIA Authoring Practices 1.2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Select-Only Combobox Example
+
+ The following example implementation of the ARIA design pattern for combobox
+ demonstrates a single-select combobox widget that is functionally similar to an HTML select
element.
+ Unlike the editable combobox examples, this select-only combobox is not made with an <input>
element, and it does not accept freeform user input.
+ However, like an HTML <select>
, users can type characters to select matching options.
+
+ Similar examples include:
+
+
+
+
+
+ Keyboard Support
+
+ The example combobox on this page implements the following keyboard interface.
+ Other variations and options for the keyboard interface are described in the
+ Keyboard Interaction section of the combobox design pattern.
+
+ Closed Combobox
+
+
+
+ Key
+ Function
+
+
+
+
+ Down Arrow
+
+
+ Opens the listbox if it is not already displayed without moving focus or changing selection.
+ DOM focus remains on the combobox.
+
+
+
+
+ Alt + Down Arrow
+
+ Opens the listbox without moving focus or changing selection.
+
+
+
+ Up Arrow
+
+
+ First opens the listbox if it is not already displayed and then moves visual focus to the first option.
+ DOM focus remains on the combobox.
+
+
+
+
+ Enter
+
+ Opens the listbox without moving focus or changing selection.
+
+
+
+ Space
+
+ Opens the listbox without moving focus or changing selection.
+
+
+
+ Home
+
+ Opens the listbox and moves visual focus to the first option.
+
+
+
+ End
+
+ Opens the listbox and moves visual focus to the last option.
+
+
+
+ Printable Characters
+
+
+ First opens the listbox if it is not already displayed and then moves visual focus to the first option that matches the typed character.
+ If multiple keys are typed in quick succession, visual focus moves to the first option that matches the full string.
+ If the same character is typed in succession, visual focus cycles among the options starting with that character
+
+
+
+
+
+ Listbox Popup
+
+ NOTE: When visual focus is in the listbox, DOM focus remains on the combobox and the value of aria-activedescendant
on the combobox is set to a value that refers to the listbox option that is visually indicated as focused.
+ Where the following descriptions of keyboard commands mention focus, they are referring to the visual focus indicator.
+ For more information about this focus management technique, see
+ Using aria-activedescendant to Manage Focus.
+
+
+
+
+ Key
+ Function
+
+
+
+
+ Enter
+
+
+ Sets the value to the content of the focused option in the listbox.
+ Closes the listbox.
+ Sets visual focus on the combobox.
+
+
+
+
+ Space
+
+
+ Sets the value to the content of the focused option in the listbox.
+ Closes the listbox.
+ Sets visual focus on the combobox.
+
+
+
+
+ Tab
+
+
+ Sets the value to the content of the focused option in the listbox.
+ Closes the listbox.
+ Performs the default action, moving focus to the next focusable element.
+ Note: the native <select>
element closes the listbox but does not move focus on tab.
+ This pattern matches the behavior of the other comboboxes rather than the native element in this case.
+
+
+
+
+ Escape
+
+
+ Closes the listbox.
+ Sets visual focus on the combobox.
+
+
+
+
+ Down Arrow
+
+
+ Moves visual focus to the next option.
+ If visual focus is on the last option, visual focus does not move.
+
+
+
+
+ Up Arrow
+
+
+ Moves visual focus to the previous option.
+ If visual focus is on the first option, visual focus does not move.
+
+
+
+
+ Alt + Up Arrow
+
+
+ Sets the value to the content of the focused option in the listbox.
+ Closes the listbox.
+ Sets visual focus on the combobox.
+
+
+
+
+ Home
+ Moves visual focus to the first option.
+
+
+ End
+ Moves visual focus to the last option.
+
+
+ PageUp
+ Jumps visual focus up 10 options (or to first option).
+
+
+ PageDown
+ Jumps visual focus down 10 options (or to last option).
+
+
+ Printable Characters
+
+
+ First opens the listbox if it is not already displayed and then moves visual focus to the first option that matches the typed character.
+ If multiple keys are typed in quick succession, visual focus moves to the first option that matches the full string.
+ If the same character is typed in succession, visual focus cycles among the options starting with that character
+
+
+
+
+
+
+
+
+ Role, Property, State, and Tabindex Attributes
+
+ The example combobox on this page implements the following ARIA roles, states, and properties.
+ Information about other ways of applying ARIA roles, states, and properties is available in the
+ Roles, States, and Properties section of the combobox design pattern.
+
+ Combobox
+
+
+
+ Role
+ Attribute
+ Element
+ Usage
+
+
+
+
+
+ combobox
+
+
+ div
+ Identifies the input as a combobox.
+
+
+
+
+ aria-labelledby="#IDREF"
+
+ div
+ Identifies the element that labels the combobox.
+
+
+
+
+ aria-controls="#IDREF"
+
+ div
+ Identifies the element that serves as the popup.
+
+
+
+
+ aria-expanded="false"
+
+ div
+ Indicates that the popup element is not displayed.
+
+
+
+
+ aria-expanded="true"
+
+ div
+ Indicates that the popup element is displayed.
+
+
+
+
+ aria-activedescendant="IDREF"
+
+ div
+
+
+ When an option in the listbox is visually indicated as having keyboard focus, refers to that option.
+ When navigation keys, such as Down Arrow , are pressed, the JavaScript changes the value.
+ Enables assistive technologies to know which element the application regards as focused while DOM focus remains on the input
element.
+
+ For more information about this focus management technique, see
+ Using aria-activedescendant to Manage Focus.
+
+
+
+
+
+
+ Listbox Popup
+
+
+
+ Role
+ Attribute
+ Element
+ Usage
+
+
+
+
+
+ listbox
+
+
+
+ div
+
+ Identifies the element as a listbox
.
+
+
+
+ option
+
+
+ div
+
+
+ Identifies the element as a listbox
option
.
+ The text content of the element provides the accessible name of the option
.
+
+
+
+
+
+
+ aria-selected="true"
+
+ li
+
+
+ Specified on an option in the listbox when it is visually highlighted as selected.
+ Occurs only when an option in the list is referenced by aria-activedescendant
.
+
+
+
+
+
+
+
+
+ Javascript and CSS Source Code
+
+
+
+
+ HTML Source Code
+
+
+
+
+
+
+
+ Combobox Design Pattern in WAI-ARIA Authoring Practices 1.2
+
+
+
diff --git a/examples/combobox/css/select-only.css b/examples/combobox/css/select-only.css
new file mode 100644
index 0000000000..6eeaf22e7a
--- /dev/null
+++ b/examples/combobox/css/select-only.css
@@ -0,0 +1,103 @@
+.combo *,
+.combo *::before,
+.combo *::after {
+ box-sizing: border-box;
+}
+
+.combo {
+ display: block;
+ margin-bottom: 1.5em;
+ max-width: 400px;
+ position: relative;
+}
+
+.combo::after {
+ border-bottom: 2px solid rgba(0, 0, 0, 0.75);
+ border-right: 2px solid rgba(0, 0, 0, 0.75);
+ content: '';
+ display: block;
+ height: 12px;
+ pointer-events: none;
+ position: absolute;
+ right: 16px;
+ top: 50%;
+ transform: translate(0, -65%) rotate(45deg);
+ width: 12px;
+}
+
+.combo-input {
+ background-color: #f5f5f5;
+ border: 2px solid rgba(0, 0, 0, 0.75);
+ border-radius: 4px;
+ display: block;
+ font-size: 1em;
+ min-height: calc(1.4em + 26px);
+ padding: 12px 16px 14px;
+ text-align: left;
+ width: 100%;
+}
+
+.open .combo-input {
+ border-radius: 4px 4px 0 0;
+}
+
+.combo-input:focus {
+ border-color: #0067b8;
+ box-shadow: 0 0 4px 2px #0067b8;
+ outline: 4px solid transparent;
+}
+
+.combo-label {
+ display: block;
+ font-size: 20px;
+ font-weight: 100;
+ margin-bottom: 0.25em;
+}
+
+.combo-menu {
+ background-color: #f5f5f5;
+ border: 1px solid rgba(0, 0, 0, 0.75);
+ border-radius: 0 0 4px 4px;
+ display: none;
+ max-height: 300px;
+ overflow-y:scroll;
+ left: 0;
+ position: absolute;
+ top: 100%;
+ width: 100%;
+ z-index: 100;
+}
+
+.open .combo-menu {
+ display: block;
+}
+
+.combo-option {
+ padding: 10px 12px 12px;
+}
+
+.combo-option:hover {
+ background-color: rgba(0, 0, 0, 0.1);
+}
+
+.combo-option.option-current {
+ outline: 3px solid #0067b8;
+ outline-offset: -3px;
+}
+
+.combo-option[aria-selected="true"] {
+ padding-right: 30px;
+ position: relative;
+}
+
+.combo-option[aria-selected="true"]::after {
+ border-bottom: 2px solid #000;
+ border-right: 2px solid #000;
+ content: '';
+ height: 16px;
+ position: absolute;
+ right: 15px;
+ top: 50%;
+ transform: translate(0, -50%) rotate(45deg);
+ width: 8px;
+}
\ No newline at end of file
diff --git a/examples/combobox/grid-combo.html b/examples/combobox/grid-combo.html
index 140def9c9e..215a0d7fd6 100644
--- a/examples/combobox/grid-combo.html
+++ b/examples/combobox/grid-combo.html
@@ -44,6 +44,7 @@ Editable Combobox with Grid Popup Example
Similar examples include:
+ Select-Only Combobox : A single-select combobox with no text input that is functionally similar to an HTML select
element.
Editable Combobox with Both List and Inline Autocomplete : An editable combobox that demonstrates the autocomplete behavior known as list with inline autocomplete .
Editable Combobox with List Autocomplete : An editable combobox that demonstrates the autocomplete behavior known as list with manual selection .
Editable Combobox Without Autocomplete : An editable combobox that demonstrates the behavior associated with aria-autocomplete=none
.
diff --git a/examples/combobox/js/select-only.js b/examples/combobox/js/select-only.js
new file mode 100644
index 0000000000..2c18abed80
--- /dev/null
+++ b/examples/combobox/js/select-only.js
@@ -0,0 +1,372 @@
+/*
+* This content is licensed according to the W3C Software License at
+* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
+*/
+
+'use strict';
+
+// Save a list of named combobox actions, for future readability
+const SelectActions = {
+ Close: 0,
+ CloseSelect: 1,
+ First: 2,
+ Last: 3,
+ Next: 4,
+ Open: 5,
+ PageDown: 6,
+ PageUp: 7,
+ Previous: 8,
+ Select: 9,
+ Type: 10
+}
+
+/*
+ * Helper functions
+ */
+
+// filter an array of options against an input string
+// returns an array of options that begin with the filter string, case-independent
+function filterOptions(options = [], filter, exclude = []) {
+ return options.filter((option) => {
+ const matches = option.toLowerCase().indexOf(filter.toLowerCase()) === 0;
+ return matches && exclude.indexOf(option) < 0;
+ });
+}
+
+// map a key press to an action
+function getActionFromKey(event, menuOpen) {
+ const { key, altKey, ctrlKey, metaKey } = event;
+ const openKeys = ['ArrowDown', 'ArrowUp', 'Enter', ' ']; // all keys that will do the default open action
+ // handle opening when closed
+ if (!menuOpen && openKeys.includes(key)) {
+ return SelectActions.Open;
+ }
+
+ // home and end move the selected option when open or closed
+ if (key === 'Home') {
+ return SelectActions.First;
+ }
+ if (key === 'End') {
+ return SelectActions.Last;
+ }
+
+ // handle typing characters when open or closed
+ if (key === 'Backspace' || key === 'Clear' || (key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey)) {
+ return SelectActions.Type;
+ }
+
+ // handle keys when open
+ if (menuOpen) {
+ if (key === 'ArrowUp' && altKey) {
+ return SelectActions.CloseSelect;
+ }
+ else if (key === 'ArrowDown' && !altKey) {
+ return SelectActions.Next;
+ }
+ else if (key === 'ArrowUp') {
+ return SelectActions.Previous;
+ }
+ else if (key === 'PageUp') {
+ return SelectActions.PageUp;
+ }
+ else if (key === 'PageDown') {
+ return SelectActions.PageDown;
+ }
+ else if (key === 'Escape') {
+ return SelectActions.Close;
+ }
+ else if (key === 'Enter' || key === ' ') {
+ return SelectActions.CloseSelect;
+ }
+ }
+}
+
+// return the index of an option from an array of options, based on a search string
+// if the filter is multiple iterations of the same letter (e.g "aaa"), then cycle through first-letter matches
+function getIndexByLetter(options, filter, startIndex = 0) {
+ const orderedOptions = [...options.slice(startIndex), ...options.slice(0, startIndex)];
+ const firstMatch = filterOptions(orderedOptions, filter)[0];
+ const allSameLetter = (array) => array.every((letter) => letter === array[0]);
+
+ // first check if there is an exact match for the typed string
+ if (firstMatch) {
+ return options.indexOf(firstMatch);
+ }
+
+ // if the same letter is being repeated, cycle through first-letter matches
+ else if (allSameLetter(filter.split(''))) {
+ const matches = filterOptions(orderedOptions, filter[0]);
+ return options.indexOf(matches[0]);
+ }
+
+ // if no matches, return -1
+ else {
+ return -1;
+ }
+}
+
+// get an updated option index after performing an action
+function getUpdatedIndex(currentIndex, maxIndex, action) {
+ const pageSize = 10; // used for pageup/pagedown
+
+ switch(action) {
+ case SelectActions.First:
+ return 0;
+ case SelectActions.Last:
+ return maxIndex;
+ case SelectActions.Previous:
+ return Math.max(0, currentIndex - 1);
+ case SelectActions.Next:
+ return Math.min(maxIndex, currentIndex + 1);
+ case SelectActions.PageUp:
+ return Math.max(0, currentIndex - pageSize);
+ case SelectActions.PageDown:
+ return Math.min(maxIndex, currentIndex + pageSize);
+ default:
+ return currentIndex;
+ }
+}
+
+// check if an element is currently scrollable
+function isScrollable(element) {
+ return element && element.clientHeight < element.scrollHeight;
+}
+
+// ensure a given child element is within the parent's visible scroll area
+// if the child is not visible, scroll the parent
+function maintainScrollVisibility(activeElement, scrollParent) {
+ const { offsetHeight, offsetTop } = activeElement;
+ const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent;
+
+ const isAbove = offsetTop < scrollTop;
+ const isBelow = (offsetTop + offsetHeight) > (scrollTop + parentOffsetHeight);
+
+ if (isAbove) {
+ scrollParent.scrollTo(0, offsetTop);
+ }
+ else if (isBelow) {
+ scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight);
+ }
+}
+
+/*
+ * Select Component
+ * Accepts a combobox element and an array of string options
+ */
+const Select = function(el, options = []) {
+ // element refs
+ this.el = el;
+ this.comboEl = el.querySelector('[role=combobox]');
+ this.listboxEl = el.querySelector('[role=listbox]');
+
+ // data
+ this.idBase = this.comboEl.id || 'combo';
+ this.options = options;
+
+ // state
+ this.activeIndex = 0;
+ this.open = false;
+ this.searchString = '';
+ this.searchTimeout = null;
+
+ // init
+ if (el && this.comboEl && this.listboxEl) {
+ this.init();
+ }
+}
+
+Select.prototype.init = function() {
+ // select first option by default
+ this.comboEl.innerHTML = this.options[0];
+
+ // add event listeners
+ this.comboEl.addEventListener('blur', this.onComboBlur.bind(this));
+ this.comboEl.addEventListener('click', this.onComboClick.bind(this));
+ this.comboEl.addEventListener('keydown', this.onComboKeyDown.bind(this));
+
+ // create options
+ this.options.map((option, index) => {
+ const optionEl = this.createOption(option, index);
+ this.listboxEl.appendChild(optionEl);
+ });
+}
+
+Select.prototype.createOption = function(optionText, index) {
+ const optionEl = document.createElement('div');
+ optionEl.setAttribute('role', 'option');
+ optionEl.id = `${this.idBase}-${index}`;
+ optionEl.className = index === 0 ? 'combo-option option-current' : 'combo-option';
+ optionEl.setAttribute('aria-selected', `${index === 0}`);
+ optionEl.innerText = optionText;
+
+ optionEl.addEventListener('click', (event) => {
+ event.stopPropagation();
+ this.onOptionClick(index);
+ });
+ optionEl.addEventListener('mousedown', this.onOptionMouseDown.bind(this));
+
+ return optionEl;
+}
+
+Select.prototype.getSearchString = function(char) {
+ // reset typing timeout and start new timeout
+ // this allows us to make multiple-letter matches, like a native select
+ if (typeof this.searchTimeout === 'number') {
+ window.clearTimeout(this.searchTimeout);
+ }
+
+ this.searchTimeout = window.setTimeout(() => {
+ this.searchString = '';
+ }, 500);
+
+ // add most recent letter to saved search string
+ this.searchString += char;
+ return this.searchString;
+}
+
+Select.prototype.onComboBlur = function() {
+ // do not do blur action if ignoreBlur flag has been set
+ if (this.ignoreBlur) {
+ this.ignoreBlur = false;
+ return;
+ }
+
+ // select current option and close
+ if (this.open) {
+ this.selectOption(this.activeIndex);
+ this.updateMenuState(false, false);
+ }
+}
+
+Select.prototype.onComboClick = function() {
+ this.updateMenuState(!this.open, false);
+}
+
+Select.prototype.onComboKeyDown = function(event) {
+ const { key } = event;
+ const max = this.options.length - 1;
+
+ const action = getActionFromKey(event, this.open);
+
+ switch(action) {
+ case SelectActions.Last:
+ case SelectActions.First:
+ this.updateMenuState(true);
+ // intentional fallthrough
+ case SelectActions.Next:
+ case SelectActions.Previous:
+ case SelectActions.PageUp:
+ case SelectActions.PageDown:
+ event.preventDefault();
+ return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action));
+ case SelectActions.CloseSelect:
+ event.preventDefault();
+ this.selectOption(this.activeIndex);
+ // intentional fallthrough
+ case SelectActions.Close:
+ event.preventDefault();
+ return this.updateMenuState(false);
+ case SelectActions.Type:
+ return this.onComboType(key);
+ case SelectActions.Open:
+ event.preventDefault();
+ return this.updateMenuState(true);
+ }
+}
+
+Select.prototype.onComboType = function(letter) {
+ // open the listbox if it is closed
+ this.updateMenuState(true);
+
+ // find the index of the first matching option
+ const searchString = this.getSearchString(letter);
+ const searchIndex = getIndexByLetter(this.options, searchString, this.activeIndex + 1);
+
+ // if a match was found, go to it
+ if (searchIndex >= 0) {
+ this.onOptionChange(searchIndex);
+ }
+ // if no matches, clear the timeout and search string
+ else {
+ window.clearTimeout(this.searchTimeout);
+ this.searchString = '';
+ }
+}
+
+Select.prototype.onOptionChange = function(index) {
+ // update state
+ this.activeIndex = index;
+
+ // update aria-activedescendant
+ this.comboEl.setAttribute('aria-activedescendant', `${this.idBase}-${index}`);
+
+ // update active option styles
+ const options = this.el.querySelectorAll('[role=option]');
+ [...options].forEach((optionEl) => {
+ optionEl.classList.remove('option-current');
+ });
+ options[index].classList.add('option-current');
+
+ // ensure the new option is in view
+ if (isScrollable(this.listboxEl)) {
+ maintainScrollVisibility(options[index], this.listboxEl);
+ }
+}
+
+Select.prototype.onOptionClick = function(index) {
+ this.onOptionChange(index);
+ this.selectOption(index);
+ this.updateMenuState(false);
+}
+
+Select.prototype.onOptionMouseDown = function() {
+ // Clicking an option will cause a blur event,
+ // but we don't want to perform the default keyboard blur action
+ this.ignoreBlur = true;
+}
+
+Select.prototype.selectOption = function(index) {
+ // update state
+ this.activeIndex = index;
+
+ // update displayed value
+ const selected = this.options[index];
+ this.comboEl.innerHTML = selected;
+
+ // update aria-selected
+ const options = this.el.querySelectorAll('[role=option]');
+ [...options].forEach((optionEl) => {
+ optionEl.setAttribute('aria-selected', 'false');
+ });
+ options[index].setAttribute('aria-selected', 'true');
+}
+
+Select.prototype.updateMenuState = function(open, callFocus = true) {
+ if (this.open === open) {
+ return;
+ }
+
+ // update state
+ this.open = open;
+
+ // update aria-expanded and styles
+ this.comboEl.setAttribute('aria-expanded', `${open}`);
+ open ? this.el.classList.add('open') : this.el.classList.remove('open');
+
+ // update activedescendant
+ const activeID = open ? `${this.idBase}-${this.activeIndex}` : '';
+ this.comboEl.setAttribute('aria-activedescendant', activeID);
+
+ // move focus back to the combobox, if needed
+ callFocus && this.comboEl.focus();
+}
+
+// init select
+window.addEventListener('load', function () {
+ const options = ['Choose a Fruit', 'Apple', 'Banana', 'Blueberry', 'Boysenberry', 'Cherry', 'Cranberry', 'Durian', 'Eggplant', 'Fig', 'Grape', 'Guava', 'Huckleberry'];
+ const selectEls = document.querySelectorAll('.js-select');
+
+ selectEls.forEach((el) => {
+ new Select(el, options);
+ });
+});
\ No newline at end of file
diff --git a/examples/index.html b/examples/index.html
index 41c52a9711..7d1ae9e3c0 100644
--- a/examples/index.html
+++ b/examples/index.html
@@ -80,6 +80,7 @@ Examples by Role
Editable Combobox With Both List and Inline Autocomplete
Editable Combobox With List Autocomplete
Editable Combobox without Autocomplete
+ Select-Only Combobox
Editable Combobox with Grid Popup
@@ -148,6 +149,7 @@ Examples by Role
Editable Combobox With Both List and Inline Autocomplete
Editable Combobox With List Autocomplete
Editable Combobox without Autocomplete
+ Select-Only Combobox
Collapsible Dropdown Listbox
Listbox with Grouped Options
Listboxes with Rearrangeable Options
@@ -225,6 +227,7 @@ Examples by Role
Editable Combobox With Both List and Inline Autocomplete
Editable Combobox With List Autocomplete
Editable Combobox without Autocomplete
+ Select-Only Combobox
@@ -298,6 +301,7 @@ Examples by Role
tab
@@ -311,6 +315,7 @@ Examples by Role
tablist
@@ -320,6 +325,7 @@ Examples by Role
tabpanel
@@ -374,6 +380,7 @@ Examples By Properties and States
Editable Combobox With Both List and Inline Autocomplete
Editable Combobox With List Autocomplete
Editable Combobox without Autocomplete
+ Select-Only Combobox
Editable Combobox with Grid Popup
Collapsible Dropdown Listbox
Listbox with Grouped Options
@@ -421,10 +428,12 @@ Examples By Properties and States
Accordion
+ Auto-Rotating Image Carousel with a Tablist
Checkbox (Mixed-State)
Editable Combobox With Both List and Inline Autocomplete
Editable Combobox With List Autocomplete
Editable Combobox without Autocomplete
+ Select-Only Combobox
Editable Combobox with Grid Popup
Disclosure (Show/Hide) for Answers to Frequently Asked Questions
Disclosure (Show/Hide) for Image Description
@@ -471,6 +480,7 @@ Examples By Properties and States
Editable Combobox With Both List and Inline Autocomplete
Editable Combobox With List Autocomplete
Editable Combobox without Autocomplete
+ Select-Only Combobox
Editable Combobox with Grid Popup
Disclosure (Show/Hide) for Answers to Frequently Asked Questions
Disclosure (Show/Hide) for Image Description
@@ -520,6 +530,7 @@ Examples By Properties and States
@@ -654,7 +666,12 @@ Examples By Properties and States
aria-roledescription
- Auto-Rotating Image Carousel
+
+
+
aria-rowcount
@@ -678,6 +695,7 @@ Examples By Properties and States
aria-selected
+ Auto-Rotating Image Carousel with a Tablist
Editable Combobox With Both List and Inline Autocomplete
Editable Combobox With List Autocomplete
Editable Combobox without Autocomplete
diff --git a/test/tests/combobox_select-only.js b/test/tests/combobox_select-only.js
new file mode 100644
index 0000000000..4bbaf076a4
--- /dev/null
+++ b/test/tests/combobox_select-only.js
@@ -0,0 +1,545 @@
+'use strict';
+
+const { ariaTest } = require('..');
+const { By, Key } = require('selenium-webdriver');
+const assertAttributeValues = require('../util/assertAttributeValues');
+const assertAriaLabelledby = require('../util/assertAriaLabelledby');
+const assertAriaRoles = require('../util/assertAriaRoles');
+
+const exampleFile = 'combobox/combobox-select-only.html';
+
+const ex = {
+ comboSelector: '#combo1',
+ listboxSelector: '#listbox1'
+};
+
+// Attributes
+ariaTest('role="combobox"', exampleFile, 'combobox-role', async (t) => {
+ await assertAriaRoles(t, 'ex1', 'combobox', '1', 'div');
+});
+
+ariaTest('role="listbox"', exampleFile, 'listbox-role', async (t) => {
+ await assertAriaRoles(t, 'ex1', 'listbox', '1', 'div');
+});
+
+ariaTest('role "option"', exampleFile, 'option-role', async (t) => {
+ // open combo
+ await t.context.session.findElement(By.css(ex.comboSelector)).click();
+
+ // query listbox children
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} > div`);
+ await Promise.all(options.map(async (option) => {
+ const role = await option.getAttribute('role');
+ t.is(role, 'option', 'Immediate descendents of the listbox should have role="option"');
+ }));
+});
+
+ariaTest('aria-labelledby on combobox', exampleFile, 'combobox-aria-labelledby', async (t) => {
+ await assertAriaLabelledby(t, ex.comboSelector);
+});
+
+ariaTest('aria-controls on combobox', exampleFile, 'combobox-aria-controls', async (t) => {
+ const controlledId = await t.context.session
+ .findElement(By.css(ex.comboSelector))
+ .getAttribute('aria-controls');
+
+ t.truthy(controlledId, '"aria-controls" should exist on the combobox');
+
+ const controlledRole = await t.context.session.findElement(By.id(controlledId)).getAttribute('role');
+
+ t.is(controlledRole, 'listbox', 'The combobox\'s aria-controls attribute should point to a listbox');
+});
+
+ariaTest('aria-expanded="false" when closed', exampleFile, 'combobox-aria-expanded', async (t) => {
+ const expanded = await t.context.session.findElement(By.css(ex.comboSelector)).getAttribute('aria-expanded');
+
+ t.is(expanded, 'false', 'aria-expanded should be false by default');
+});
+
+ariaTest('click opens combobox and sets aria-expanded="true"', exampleFile, 'combobox-aria-expanded', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+
+ await combobox.click();
+ const expanded = await combobox.getAttribute('aria-expanded');
+ const popupDisplayed = await t.context.session.findElement(By.css(ex.listboxSelector)).isDisplayed();
+
+ t.is(expanded, 'true', 'aria-expanded should be true when opened');
+ t.true(popupDisplayed, 'listbox should be present after click');
+});
+
+ariaTest('"aria-activedescendant" on combobox element', exampleFile, 'combobox-aria-activedescendant', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const firstOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`));
+ const optionId = await firstOption.getAttribute('id');
+
+ await combobox.click();
+
+ await assertAttributeValues(t, ex.comboSelector, 'aria-activedescendant', optionId);
+});
+
+ariaTest('"aria-selected" attribute on first option', exampleFile, 'option-aria-selected', async (t) => {
+ const firstOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`));
+ const secondOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(2)`));
+
+ t.is(await firstOption.getAttribute('aria-selected'), 'true', 'the first option is selected by default');
+ t.is(await secondOption.getAttribute('aria-selected'), 'false', 'other options have aria-selected set to false');
+});
+
+// Behavior
+
+// Open listbox
+ariaTest('Alt + down arrow opens listbox', exampleFile, 'combobox-key-alt-down-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`)).getAttribute('id');
+
+ // listbox starts collapsed
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden on load');
+
+ // Send ALT + ARROW_DOWN to the combo
+ await combobox.sendKeys(Key.chord(Key.ALT, Key.ARROW_DOWN));
+
+ // Check that the listbox is displayed
+ t.true(await listbox.isDisplayed(), 'alt + down should show the listbox');
+ t.is(await combobox.getAttribute('aria-expanded'), 'true', 'aria-expanded should be true when opened');
+
+ // the first option should be selected
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'Alt + Down should highlight the first option');
+});
+
+ariaTest('Up arrow opens listbox', exampleFile, 'combobox-key-up-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`)).getAttribute('id');
+
+ // listbox starts collapsed
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden on load');
+
+ // Send ARROW_UP to the combo
+ await combobox.sendKeys(Key.ARROW_UP);
+
+ // Check that the listbox is displayed
+ t.true(await listbox.isDisplayed(), 'arrow up should show the listbox');
+ t.is(await combobox.getAttribute('aria-expanded'), 'true', 'aria-expanded should be true when opened');
+
+ // the first option should be selected
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'arrow up should highlight the first option');
+});
+
+
+ariaTest(' arrow opens listbox', exampleFile, 'combobox-key-down-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`)).getAttribute('id');
+
+ // listbox starts collapsed
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden on load');
+
+ // Send ARROW_DOWN to the combo
+ await combobox.sendKeys(Key.ARROW_DOWN);
+
+ // Check that the listbox is displayed
+ t.true(await listbox.isDisplayed(), 'alt + down should show the listbox');
+ t.is(await combobox.getAttribute('aria-expanded'), 'true', 'aria-expanded should be true when opened');
+
+ // the first option should be selected
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'Down arrow should highlight the first option');
+});
+
+ariaTest('Enter opens listbox', exampleFile, 'combobox-key-enter', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`)).getAttribute('id');
+
+ // listbox starts collapsed
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden on load');
+
+ // Send ENTER to the combo
+ await combobox.sendKeys(Key.ENTER);
+
+ // Check that the listbox is displayed
+ t.true(await listbox.isDisplayed(), 'enter should show the listbox');
+
+ // the first option should be selected
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'enter should highlight the first option');
+});
+
+ariaTest('Space opens listbox', exampleFile, 'combobox-key-space', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`)).getAttribute('id');
+
+ // listbox starts collapsed
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden on load');
+
+ // Send space to the combo
+ await combobox.sendKeys(' ');
+
+ // Check that the listbox is displayed
+ t.true(await listbox.isDisplayed(), 'space should show the listbox');
+
+ // the first option should be selected
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'space should highlight the first option');
+});
+
+ariaTest('combobox opens on last highlighted option', exampleFile, 'combobox-key-down-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const secondOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(2)`)).getAttribute('id');
+
+ // Open, select second option, close
+ await combobox.sendKeys(' ');
+ await combobox.sendKeys(Key.ARROW_DOWN);
+ await combobox.sendKeys(Key.ESCAPE);
+
+ // Open again
+ await combobox.sendKeys(' ');
+
+ // Check that the listbox is displayed and second option is highlighted
+ t.true(await listbox.isDisplayed(), 'space should show the listbox');
+ t.is(await combobox.getAttribute('aria-activedescendant'), secondOptionId, 'second option should be highlighted');
+});
+
+ariaTest('Home opens listbox to first option', exampleFile, 'combobox-key-home', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`)).getAttribute('id');
+
+ // Open, select second option, close
+ await combobox.sendKeys(' ');
+ await combobox.sendKeys(Key.ARROW_DOWN);
+ await combobox.sendKeys(Key.ESCAPE);
+
+ // listbox is collapsed
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden');
+
+ // Send home key to the combo
+ await combobox.sendKeys(Key.HOME);
+
+ // Check that the listbox is displayed and first option is highlighted
+ t.true(await listbox.isDisplayed(), 'home should show the listbox');
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'home should always highlight the first option');
+});
+
+ariaTest('End opens listbox to last option', exampleFile, 'combobox-key-end', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} [role=option]`);
+ const lastOptionId = await options[options.length - 1].getAttribute('id');
+
+ // Send end key to the combo
+ await combobox.sendKeys(Key.END);
+
+ // Check that the listbox is displayed and first option is highlighted
+ t.true(await listbox.isDisplayed(), 'end should show the listbox');
+ t.is(await combobox.getAttribute('aria-activedescendant'), lastOptionId, 'end should always highlight the last option');
+});
+
+ariaTest('character keys open listbox to matching option', exampleFile, 'printable-chars', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const secondOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(2)`)).getAttribute('id');
+
+ // type "a"
+ await combobox.sendKeys('a');
+
+ // Check that the listbox is displayed and the second option is highlighted
+ // bit of hard-coding here; we know that the second option begins with "a", since the first is a placeholder
+ t.true(await listbox.isDisplayed(), 'character key should show the listbox');
+ t.is(await combobox.getAttribute('aria-activedescendant'), secondOptionId, 'typing "a" should highlight the first option beginning with "a"');
+});
+
+// Close listbox
+ariaTest('click opens and closes listbox', exampleFile, 'test-additional-behavior', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+
+ await combobox.click();
+ t.true(await listbox.isDisplayed(), 'listbox should be present after click');
+
+ await combobox.click();
+ t.false(await listbox.isDisplayed(), 'second click should close listbox');
+ t.is(await combobox.getAttribute('aria-expanded'), 'false', 'aria-expanded should be set to false after second click');
+});
+
+ariaTest('clicking an option selects and closes', exampleFile, 'test-additional-behavior', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const fourthOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(4)`));
+
+ await combobox.click();
+ const fourthOptionText = await fourthOption.getText();
+ t.true(await listbox.isDisplayed(), 'listbox should be present after click');
+
+ await fourthOption.click();
+ t.false(await listbox.isDisplayed(), 'option click should close listbox');
+ t.is(await combobox.getText(), fourthOptionText, 'Combobox inner text should match the clicked option');
+ t.is(await fourthOption.getAttribute('aria-selected'), 'true', 'Clicked option has aria-selected set to true');
+});
+
+ariaTest('Enter closes listbox and selects option', exampleFile, 'listbox-key-enter', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const thirdOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(3)`));
+
+ // Open, move to third option, hit enter
+ await combobox.sendKeys(Key.ENTER, Key.ARROW_DOWN, Key.ARROW_DOWN);
+ const thirdOptionText = await thirdOption.getText();
+ await combobox.sendKeys(Key.ENTER);
+
+
+ // listbox is collapsed and the value is set to the third option
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden');
+ t.is(await combobox.getAttribute('aria-expanded'), 'false', 'test aria-expanded on combo');
+ t.is(await combobox.getText(), thirdOptionText, 'Combobox inner text should match the third option');
+ t.is(await thirdOption.getAttribute('aria-selected'), 'true', 'Third option has aria-selected set to true');
+});
+
+ariaTest('Space closes listbox and selects option', exampleFile, 'listbox-key-space', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const secondOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(2)`));
+
+ // Open, move to third option, hit space
+ await combobox.sendKeys(Key.ENTER, Key.ARROW_DOWN);
+ const secondOptionText = await secondOption.getText();
+ await combobox.sendKeys(' ');
+
+ // listbox is collapsed and the value is set to the third option
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden');
+ t.is(await combobox.getText(), secondOptionText, 'Combobox inner text should match the second option');
+ t.is(await secondOption.getAttribute('aria-selected'), 'true', 'Second option has aria-selected set to true');
+});
+
+ariaTest('Space closes listbox and selects option', exampleFile, 'listbox-key-alt-up-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const secondOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(2)`));
+
+ // Open, move to third option, send ALT+UP ARROW
+ await combobox.sendKeys(Key.ENTER, Key.ARROW_DOWN);
+ const secondOptionText = await secondOption.getText();
+ await combobox.sendKeys(Key.chord(Key.ALT, Key.ARROW_UP));
+
+ // listbox is collapsed and the value is set to the third option
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden');
+ t.is(await combobox.getText(), secondOptionText, 'Combobox inner text should match the second option');
+ t.is(await secondOption.getAttribute('aria-selected'), 'true', 'Second option has aria-selected set to true');
+});
+
+
+ariaTest('Escape closes listbox without selecting option', exampleFile, 'listbox-key-escape', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+
+ // Open, move to third option, hit enter
+ await combobox.sendKeys(Key.ENTER, Key.ARROW_DOWN, Key.ARROW_DOWN);
+ const firstOptionText = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]`)).getText();
+ await combobox.sendKeys(Key.ESCAPE);
+
+ // listbox is collapsed and the value is still set to the first option
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden');
+ t.is(await combobox.getText(), firstOptionText, 'Combobox inner text should match the first option');
+});
+
+ariaTest('Tab closes listbox and selects option', exampleFile, 'listbox-key-tab', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const fourthOption = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(4)`));
+
+ // Open, move to fourth option, hit tab
+ await combobox.sendKeys(Key.ENTER, Key.ARROW_DOWN, Key.ARROW_DOWN, Key.ARROW_DOWN);
+ const fourthOptionText = await fourthOption.getText();
+ await combobox.sendKeys(Key.TAB);
+
+ // listbox is collapsed and the value is set to the fourth option
+ t.false(await listbox.isDisplayed(), 'Listbox should be hidden');
+ t.is(await combobox.getText(), fourthOptionText, 'Combobox inner text should match the second option');
+ t.is(await fourthOption.getAttribute('aria-selected'), 'true', 'Fourth option has aria-selected set to true');
+});
+
+// Changing options
+ariaTest('Down arrow moves to next option', exampleFile, 'listbox-key-down-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const thirdOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:nth-child(3)`)).getAttribute('id');
+
+ // Open, press down arrow
+ await combobox.click();
+ await combobox.sendKeys(Key.ARROW_DOWN, Key.ARROW_DOWN);
+
+ // Second option is highlighted
+ t.is(await combobox.getAttribute('aria-activedescendant'), thirdOptionId, 'aria-activedescendant points to the third option after two down arrows');
+});
+
+ariaTest('Down arrow does not wrap after last option', exampleFile, 'listbox-key-down-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const lastOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:last-child`)).getAttribute('id');
+
+ // Open, press end, press down arrow
+ await combobox.click();
+ await combobox.sendKeys(Key.END, Key.ARROW_DOWN);
+
+ // last option is highlighted
+ t.is(await combobox.getAttribute('aria-activedescendant'), lastOptionId, 'aria-activedescendant points to the last option after end + down arrow');
+});
+
+ariaTest('Up arrow does not wrap from first option', exampleFile, 'listbox-key-up-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:first-child`)).getAttribute('id');
+
+ // Open, press up arrow
+ await combobox.click();
+ await combobox.sendKeys(Key.ARROW_UP);
+
+ // first option is highlighted
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'aria-activedescendant points to the first option after up arrow');
+});
+
+ariaTest('Up arrow moves to previous option', exampleFile, 'listbox-key-up-arrow', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} [role=option]`);
+ const optionId = await options[options.length - 2].getAttribute('id');
+
+ // Open, press end + up arrow
+ await combobox.click();
+ await combobox.sendKeys(Key.END, Key.ARROW_UP);
+
+ // second to last option is highlighted
+ t.is(await combobox.getAttribute('aria-activedescendant'), optionId, 'aria-activedescendant points to the second-to-last option after end + up arrow');
+});
+
+ariaTest('End moves to last option', exampleFile, 'listbox-key-end', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const lastOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:last-child`)).getAttribute('id');
+
+ // Open, press end
+ await combobox.click();
+ await combobox.sendKeys(Key.END);
+
+ // last option is highlighted
+ t.is(await combobox.getAttribute('aria-activedescendant'), lastOptionId, 'aria-activedescendant points to the last option after end');
+});
+
+ariaTest('End scrolls last option into view', exampleFile, 'test-additional-behavior', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const listbox = await t.context.session.findElement(By.css(ex.listboxSelector));
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} [role=option]`);
+
+ await combobox.click();
+
+ let listboxBounds = await listbox.getRect();
+ let optionBounds = await options[options.length - 1].getRect();
+
+ t.true(listboxBounds.y + listboxBounds.height - optionBounds.y < 0, 'last option is not initially displayed');
+
+ await combobox.sendKeys(Key.END);
+ listboxBounds = await listbox.getRect();
+ optionBounds = await options[options.length - 1].getRect();
+
+ t.true(listboxBounds.y + listboxBounds.height - optionBounds.y >= 0, 'last option is in view after end key');
+});
+
+ariaTest('Home moves to first option', exampleFile, 'listbox-key-home', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const firstOptionId = await t.context.session.findElement(By.css(`${ex.listboxSelector} [role=option]:first-child`)).getAttribute('id');
+
+ // Open, press down a couple times, then home
+ await combobox.click();
+ await combobox.sendKeys(Key.ARROW_DOWN, Key.ARROW_DOWN, Key.HOME);
+
+ // first option is highlighted
+ t.is(await combobox.getAttribute('aria-activedescendant'), firstOptionId, 'aria-activedescendant points to the first option after home');
+});
+
+ariaTest('PageDown moves 10 options, and does not wrap', exampleFile, 'listbox-key-pagedown', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} [role=option]`);
+
+ // Open, press page down
+ await combobox.click();
+ await combobox.sendKeys(Key.PAGE_DOWN);
+
+ // 11th option is highlighted
+ let optionId = await options[10].getAttribute('id');
+ t.is(await combobox.getAttribute('aria-activedescendant'), optionId, 'aria-activedescendant points to the 10th option after pagedown');
+
+ // last option is highlighted
+ await combobox.sendKeys(Key.PAGE_DOWN);
+ optionId = await options[options.length - 1].getAttribute('id');
+ t.is(await combobox.getAttribute('aria-activedescendant'), optionId, 'aria-activedescendant points to the last option after second pagedown');
+});
+
+ariaTest('PageUp moves up 10 options, and does not wrap', exampleFile, 'listbox-key-pageup', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} [role=option]`);
+
+ // Open, press end then page up
+ await combobox.click();
+ await combobox.sendKeys(Key.END, Key.PAGE_UP);
+
+ // 11th-from-last option is highlighted
+ let optionId = await options[options.length - 11].getAttribute('id');
+ t.is(await combobox.getAttribute('aria-activedescendant'), optionId, 'aria-activedescendant points to the 10th-from-last option after end + pageup');
+
+ // first option is highlighted
+ await combobox.sendKeys(Key.PAGE_UP);
+ optionId = await options[0].getAttribute('id');
+ t.is(await combobox.getAttribute('aria-activedescendant'), optionId, 'aria-activedescendant points to the first option after second pageup');
+});
+
+ariaTest('Multiple single-character presses cycle through options', exampleFile, 'printable-chars', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} [role=option]`);
+
+ // Open, then type "a"
+ await combobox.click();
+
+ // get indices of matching options
+ const optionNames = await Promise.all(options.map(async (option) => {
+ return await option.getText();
+ }));
+ const matchingOps = optionNames
+ .filter((name) => name[0].toLowerCase() === 'b')
+ .map((name) => optionNames.indexOf(name));
+
+
+ // type b, check first matching op is highlighted
+ await combobox.sendKeys('b');
+ let matchingId = await options[matchingOps[0]].getAttribute('id');
+ t.is(await combobox.getAttribute('aria-activedescendant'), `${matchingId}`, 'aria-activedescendant points to the first option beginning with "b"');
+
+ // type b again, second matching option is highlighted
+ await combobox.sendKeys('b');
+ matchingId = await options[matchingOps[1]].getAttribute('id');
+ t.is(await combobox.getAttribute('aria-activedescendant'), `${matchingId}`, 'aria-activedescendant points to the second option beginning with "b"');
+
+ // type "b" as many times as there are matching options
+ // focus should wrap and end up on second option again
+ const keys = matchingOps.map((op) => 'b');
+ await combobox.sendKeys(...keys);
+ matchingId = await options[matchingOps[1]].getAttribute('id');
+ t.is(await combobox.getAttribute('aria-activedescendant'), `${matchingId}`, 'aria-activedescendant points to the second option beginning with "b"');
+});
+
+ariaTest('Typing multiple characters refines search', exampleFile, 'printable-chars', async (t) => {
+ const combobox = await t.context.session.findElement(By.css(ex.comboSelector));
+ const options = await t.context.queryElements(t, `${ex.listboxSelector} [role=option]`);
+
+ await combobox.click();
+
+ // get info about the fourth option
+ const fourthName = await options[3].getText();
+ const fourthId = await options[3].getAttribute('id');
+
+ // type first letter
+ await combobox.sendKeys(fourthName[0]);
+
+ // fourth op should not be hightlighted after only first letter
+ t.not(await combobox.getAttribute('aria-activedescendant'), fourthId, 'The fourth option is not highlighted after typing only the first letter');
+
+ // type more letters
+ await combobox.sendKeys(...fourthName.slice(1, 4).split(''));
+
+ // now fourth option should be highlighted
+ t.is(await combobox.getAttribute('aria-activedescendant'), fourthId, 'The fourth option is highlighted after typing multiple letters');
+});
diff --git a/test/util/assertAriaControls.js b/test/util/assertAriaControls.js
index 243ae5bca2..899d128109 100644
--- a/test/util/assertAriaControls.js
+++ b/test/util/assertAriaControls.js
@@ -12,11 +12,6 @@ const assert = require('assert');
module.exports = async function assertAriaControls (t, elementSelector) {
const elements = await t.context.queryElements(t, elementSelector);
- assert.ok(
- elements.length,
- 'CSS elector returned no results: ' + elementSelector
- );
-
for (let element of elements) {
const ariaControlsExists = await t.context.session.executeScript(async function () {
const selector = arguments[0];
diff --git a/test/util/assertAriaDescribedby.js b/test/util/assertAriaDescribedby.js
index a9600f576a..f679c2c287 100644
--- a/test/util/assertAriaDescribedby.js
+++ b/test/util/assertAriaDescribedby.js
@@ -13,11 +13,6 @@ const assert = require('assert');
module.exports = async function assertAriaDescribedby (t, elementSelector) {
const elements = await t.context.queryElements(t, elementSelector);
- assert.ok(
- elements.length,
- 'CSS elector returned no results: ' + elementSelector
- );
-
for (let index = 0; index < elements.length; index++) {
let ariaDescribedbyExists = await t.context.session.executeScript(async function () {
diff --git a/test/util/assertAriaLabelExists.js b/test/util/assertAriaLabelExists.js
index e62cac4dcd..fd2519d90e 100644
--- a/test/util/assertAriaLabelExists.js
+++ b/test/util/assertAriaLabelExists.js
@@ -10,14 +10,8 @@ const assert = require('assert');
*/
module.exports = async function assertAriaLabel (t, elementSelector) {
-
const elements = await t.context.queryElements(t, elementSelector);
- assert.ok(
- elements.length,
- 'CSS elector returned no results: ' + elementSelector
- );
-
for (let index = 0; index < elements.length; index++) {
let ariaLabelExists = await t.context.session.executeScript(async function () {
diff --git a/test/util/assertAriaLabelledby.js b/test/util/assertAriaLabelledby.js
index fa2753812a..1d4bf2dfc3 100644
--- a/test/util/assertAriaLabelledby.js
+++ b/test/util/assertAriaLabelledby.js
@@ -12,11 +12,6 @@ const assert = require('assert');
module.exports = async function assertAriaLabelledby (t, elementSelector) {
const elements = await t.context.queryElements(t, elementSelector);
- assert.ok(
- elements.length,
- 'CSS elector returned no results: ' + elementSelector
- );
-
for (let index = 0; index < elements.length; index++) {
const ariaLabelledbyExists = await t.context.session.executeScript(async function () {
const [selector, index] = arguments;
diff --git a/test/util/assertAttributeDNE.js b/test/util/assertAttributeDNE.js
index 4073bef921..741ad12b87 100644
--- a/test/util/assertAttributeDNE.js
+++ b/test/util/assertAttributeDNE.js
@@ -10,14 +10,8 @@ const assert = require('assert');
* @param {String} attribute - attribute that should not exist
*/
module.exports = async function assertAttributeDNE (t, selector, attribute) {
-
const numElements = (await t.context.queryElements(t, selector)).length;
- assert.ok(
- numElements,
- 'CSS elector returned no results: ' + selector
- );
-
for (let index = 0; index < numElements; index++) {
const attributeExists = await t.context.session.executeScript(function () {
let [selector, index, attribute] = arguments;
diff --git a/test/util/assertAttributeValues.js b/test/util/assertAttributeValues.js
index 724d5fe978..05d7662ca6 100644
--- a/test/util/assertAttributeValues.js
+++ b/test/util/assertAttributeValues.js
@@ -13,11 +13,6 @@ const assert = require('assert');
module.exports = async function assertAttributeValues (t, elementSelector, attribute, value) {
let elements = await t.context.queryElements(t, elementSelector);
- assert.ok(
- elements.length,
- 'CSS elector returned no results: ' + elementSelector
- );
-
for (let element of elements) {
assert.strictEqual(
await element.getAttribute(attribute),
diff --git a/test/util/assertRovingTabindex.js b/test/util/assertRovingTabindex.js
index a87d008a2c..20cf4ab42b 100644
--- a/test/util/assertRovingTabindex.js
+++ b/test/util/assertRovingTabindex.js
@@ -15,11 +15,6 @@ module.exports = async function assertRovingTabindex (t, elementsSelector, key)
// tabindex='0' is expected on the first element
let elements = await t.context.queryElements(t, elementsSelector);
- assert.ok(
- elements.length,
- 'CSS elector returned no results: ' + elementsSelector
- );
-
// test only one element has tabindex="0"
for (let tabableEl = 0; tabableEl < elements.length; tabableEl++) {
for (let el = 0; el < elements.length; el++) {