From 704876a247081bd6a3130592efdc35d2297d469a Mon Sep 17 00:00:00 2001 From: Sarah Higley Date: Sun, 3 May 2020 00:30:13 -0700 Subject: [PATCH 01/12] initial buildout of select-only combo --- examples/combobox/combobox-select-only.html | 449 ++++++++++++++++++++ examples/combobox/css/select-only.css | 150 +++++++ examples/combobox/js/select-only.js | 278 ++++++++++++ 3 files changed, 877 insertions(+) create mode 100644 examples/combobox/combobox-select-only.html create mode 100644 examples/combobox/css/select-only.css create mode 100644 examples/combobox/js/select-only.js diff --git a/examples/combobox/combobox-select-only.html b/examples/combobox/combobox-select-only.html new file mode 100644 index 0000000000..aa819a0e7e --- /dev/null +++ b/examples/combobox/combobox-select-only.html @@ -0,0 +1,449 @@ + + + + +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. + Contrary to other combobox examples, this combobox has no <input> element, and does not take freeform user input. Like a <select> a user can still type characters to auto-select matching options, however. +

+

Similar examples include:

+ + +
+

Example

+ +
+ +
+ +
+ + +
+
+
+ +
+ +
+

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. +

+

Textbox

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyFunction
Down 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 textbox.
  • +
+
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 last option.
  • +
  • DOM focus remains on the textbox.
  • +
+
Enter +
    +
  • Sets the textbox value to the content of the selected option.
  • +
  • Closes the listbox if it is displayed.
  • +
+
Standard single line text editing keys +
    +
  • Keys used for cursor movement and text manipulation, such as Delete and Shift + Right Arrow.
  • +
  • An HTML input with type="text" is used for the textbox so the browser will provide platform-specific editing keys.
  • +
+
+

Listbox Popup

+

+ NOTE: When visual focus is in the listbox, DOM focus remains on the textbox and the value of aria-activedescendant on the textbox 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. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyFunction
Enter +
    +
  • Sets the textbox value to the content of the focused option in the listbox.
  • +
  • Closes the listbox.
  • +
  • Sets visual focus on the textbox.
  • +
+
Escape +
    +
  • Closes the listbox.
  • +
  • Sets visual focus on the textbox.
  • +
+
Down Arrow +
    +
  • Moves visual focus to the next option.
  • +
  • If visual focus is on the last option, moves visual focus to the first option.
  • +
  • Note: This wrapping behavior is useful when Home and End move the editing cursor as described below.
  • +
+
Up Arrow +
    +
  • Moves visual focus to the previous option.
  • +
  • If visual focus is on the first option, moves visual focus to the last option.
  • +
  • Note: This wrapping behavior is useful when Home and End move the editing cursor as described below.
  • +
+
Right ArrowMoves visual focus to the textbox and moves the editing cursor one character to the right.
Left ArrowMoves visual focus to the textbox and moves the editing cursor one character to the left.
HomeMoves visual focus to the textbox and places the editing cursor at the beginning of the field.
EndMoves visual focus to the textbox and places the editing cursor at the end of the field.
Printable Characters +
    +
  • Moves visual focus to the textbox.
  • +
  • Types the character in the textbox.
  • +
  • Options in the listbox are not filtered based on the characters in the textbox.
  • +
+
+

Button

+

+ The button has been removed from the tab sequence of the page, but is still important to assistive technologies for mobile devices that use touch events to open the list of options. +

+
+ +
+

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. +

+

Textbox

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleAttributeElementUsage
+ combobox + input[type="text"]Identifies the input as a combobox.
+ aria-autocomplete="none" + input[type="text"]Indicates that the suggestions in the combobox popup are not values that complete the current textbox input.
+ aria-controls="#IDREF" + input[type="text"]Identifies the element that serves as the popup.
+ aria-expanded="false" + input[type="text"]Indicates that the popup element is not displayed.
+ aria-expanded="true" + input[type="text"]Indicates that the popup element is displayed.
+ id="string" + input[type="text"] +
    +
  • Referenced by for attribute of label element to provide an accessible name.
  • +
  • Recommended naming method for HTML input elements because clicking label focuses input.
  • +
+
+ aria-activedescendant="IDREF" + input[type="text"] +
    +
  • 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

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleAttributeElementUsage
+ listbox + + ul + Identifies the ul element as a listbox.
+ aria-label="Previous Searches" + ulProvides a label for the listbox.
+ option + li +
    +
  • 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.
  • +
+
+

Button

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RoleAttributeElementUsage
+ tabindex="-1" + buttonRemoves the button from the tab sequence of the page, since it's keyboard function is redundant with the keyboard operation of the textbox to open the listbox.
+ aria-label="Previous Searches" + buttonProvides a label for the button.
+ aria-controls="#IDREF" + buttonIdentifies the element that serves as the popup.
+ aria-expanded="false" + buttonIndicates that the popup element is not displayed.
+ aria-expanded="true" + buttonIndicates that the popup element is displayed.
+
+ +
+

Javascript and CSS Source Code

+ +
+ +
+

HTML Source Code

+ +
+ + +
+
+ + + diff --git a/examples/combobox/css/select-only.css b/examples/combobox/css/select-only.css new file mode 100644 index 0000000000..856d423803 --- /dev/null +++ b/examples/combobox/css/select-only.css @@ -0,0 +1,150 @@ +.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,.5); + border-right: 2px solid rgba(0,0,0,.5); + 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,.5); + 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: 5px 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,.42); + 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.option-current, +.combo-option:hover { + background-color: rgba(0,0,0,0.1); +} + +.combo-option.option-selected { + padding-right: 30px; + position: relative; +} + +.combo-option.option-selected::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; +} + +/* multiselect list of selected options */ +.selected-options { + list-style-type: none; + margin: 0; + max-width: 400px; + padding: 0; +} + +.selected-options li { + display: inline-block; + margin-bottom: 5px; +} + +.remove-option { + background-color: #6200ee; + border: 1px solid #6200ee; + border-radius: 3px; + color: #fff; + font-size: 0.75em; + font-weight: bold; + margin-bottom: 6px; + margin-right: 6px; + padding: 0.25em 1.75em 0.25em 0.25em; + position: relative; +} + +.remove-option:focus { + border-color: #baa1dd; + box-shadow: 0 0 3px 1px #6200ee; + outline: 3px solid transparent; +} + +.remove-option::before, +.remove-option::after { + border-right: 2px solid #fff; + content: ""; + height: 1em; + right: 0.75em; + position: absolute; + top: 50%; + width: 0; +} + +.remove-option::before { + transform: translate(0, -50%) rotate(45deg); +} + +.remove-option::after { + transform: translate(0, -50%) rotate(-45deg); +} \ No newline at end of file diff --git a/examples/combobox/js/select-only.js b/examples/combobox/js/select-only.js new file mode 100644 index 0000000000..5b66962e73 --- /dev/null +++ b/examples/combobox/js/select-only.js @@ -0,0 +1,278 @@ +const MenuActions = { + Close: 0, + CloseSelect: 1, + First: 2, + Last: 3, + Next: 4, + Open: 5, + Previous: 6, + Select: 7, + Space: 8, + Type: 9 +} + +/* + * 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; + }); +} + +// return a combobox action from a key press +function getActionFromKey(event, menuOpen) { + const { key, altKey, ctrlKey, metaKey } = event; + // handle opening when closed + if (!menuOpen && (key === 'ArrowDown' || key === 'Enter' || key === ' ')) { + return MenuActions.Open; + } + + // handle keys when open + if (key === 'ArrowDown') { + return MenuActions.Next; + } + else if (key === 'ArrowUp') { + return MenuActions.Previous; + } + else if (key === 'Home') { + return MenuActions.First; + } + else if (key === 'End') { + return MenuActions.Last; + } + else if (key === 'Escape') { + return MenuActions.Close; + } + else if (key === 'Enter') { + return MenuActions.CloseSelect; + } + else if (key === ' ') { + return MenuActions.Space; + } + else if (key === 'Backspace' || key === 'Clear' || (key.length === 1 && !altKey && !ctrlKey && !metaKey)) { + return MenuActions.Type; + } +} + +// get index of option that matches a string +// if the filter is multiple iterations of the same letter (e.g "aaa"), +// then return the nth match of the single letter +function getIndexByLetter(options, filter) { + const firstMatch = filterOptions(options, filter)[0]; + const allSameLetter = (array) => array.every((letter) => letter === array[0]); + + if (firstMatch) { + return options.indexOf(firstMatch); + } + else if (allSameLetter(filter.split(''))) { + const matches = filterOptions(options, filter[0]); + const matchIndex = (filter.length - 1) % matches.length; + return options.indexOf(matches[matchIndex]); + } + else { + return -1; + } +} + +// get updated option index +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; + } +} + +// check if an element is currently scrollable +function isScrollable(element) { + return element && element.clientHeight < element.scrollHeight; +} + +// ensure given child element is within the parent's visible scroll area +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 + */ +const Select = function(el, options = []) { + // element refs + this.el = el; + this.comboEl = el.querySelector('[role=combobox]'); + this.valueEl = this.comboEl.querySelector('span'); + this.listboxEl = el.querySelector('[role=listbox]'); + + // data + this.idBase = this.comboEl.id; + this.options = options; + + // state + this.activeIndex = 0; + this.open = false; + this.searchString = ''; + this.searchTimeout = null; +} + +Select.prototype.init = function() { + this.valueEl.innerHTML = this.options[0]; + + this.comboEl.addEventListener('blur', this.onComboBlur.bind(this)); + this.comboEl.addEventListener('click', () => this.updateMenuState(true)); + this.comboEl.addEventListener('keydown', this.onComboKeyDown.bind(this)); + + this.options.map((option, 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 = option; + + optionEl.addEventListener('click', (event) => { + event.stopPropagation(); + this.onOptionClick(index); + }); + optionEl.addEventListener('mousedown', this.onOptionMouseDown.bind(this)); + + this.listboxEl.appendChild(optionEl); + }); +} + +Select.prototype.getSearchString = function(char) { + if (typeof this.searchTimeout === 'number') { + window.clearTimeout(this.searchTimeout); + } + + this.searchTimeout = window.setTimeout(() => { + this.searchString = ''; + }, 1000); + + this.searchString += char; + return this.searchString; +} + +Select.prototype.onComboKeyDown = function(event) { + const { key } = event; + const max = this.options.length - 1; + + const action = getActionFromKey(event, this.open); + + switch(action) { + case MenuActions.Next: + case MenuActions.Last: + case MenuActions.First: + case MenuActions.Previous: + event.preventDefault(); + return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action)); + case MenuActions.CloseSelect: + case MenuActions.Space: + event.preventDefault(); + this.selectOption(this.activeIndex); + // intentional fallthrough + case MenuActions.Close: + event.preventDefault(); + return this.updateMenuState(false); + case MenuActions.Type: + this.updateMenuState(true); + var searchString = this.getSearchString(key); + return this.onOptionChange(Math.max(0, getIndexByLetter(this.options, searchString))); + case MenuActions.Open: + event.preventDefault(); + return this.updateMenuState(true); + } +} + +Select.prototype.onComboBlur = function() { + if (this.ignoreBlur) { + this.ignoreBlur = false; + return; + } + + if (this.open) { + this.selectOption(this.activeIndex); + this.updateMenuState(false, false); + } +} + +Select.prototype.onOptionChange = function(index) { + this.activeIndex = index; + this.comboEl.setAttribute('aria-activedescendant', `${this.idBase}-${index}`); + + // update active style + const options = this.el.querySelectorAll('[role=option]'); + [...options].forEach((optionEl) => { + optionEl.classList.remove('option-current'); + }); + options[index].classList.add('option-current'); + + 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() { + this.ignoreBlur = true; +} + +Select.prototype.selectOption = function(index) { + const selected = this.options[index]; + this.valueEl.innerHTML = selected; + this.activeIndex = index; + + // 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) { + this.open = open; + + this.comboEl.setAttribute('aria-expanded', `${open}`); + open ? this.el.classList.add('open') : this.el.classList.remove('open'); + callFocus && this.comboEl.focus(); + + // update activedescendant + const activeID = open ? `${this.idBase}-${this.activeIndex}` : this.valueEl.id; + this.comboEl.setAttribute('aria-activedescendant', activeID); +} + +// init select +window.addEventListener('load', function () { + const selectEl = document.querySelector('.js-select'); + const options = ['Apple', 'Banana', 'Blueberry', 'Boysenberry', 'Cherry', 'Durian', 'Eggplant', 'Fig', 'Grape', 'Guava', 'Huckleberry']; + const selectComponent = new Select(selectEl, options); + selectComponent.init(); +}); \ No newline at end of file From c0e883893e950028a6742a80646d995115844354 Mon Sep 17 00:00:00 2001 From: Sarah Higley Date: Sun, 3 May 2020 20:28:03 -0700 Subject: [PATCH 02/12] update example code and docs --- examples/combobox/combobox-select-only.html | 193 ++++------------- examples/combobox/css/select-only.css | 73 ++----- examples/combobox/js/select-only.js | 227 ++++++++++++-------- 3 files changed, 200 insertions(+), 293 deletions(-) diff --git a/examples/combobox/combobox-select-only.html b/examples/combobox/combobox-select-only.html index aa819a0e7e..802b3522ce 100644 --- a/examples/combobox/combobox-select-only.html +++ b/examples/combobox/combobox-select-only.html @@ -42,25 +42,24 @@

Select-Only Combobox Example

Example

- +
- -
- - -
+ +
+ + +
@@ -73,7 +72,7 @@

Keyboard Support

Other variations and options for the keyboard interface are described in the Keyboard Interaction section of the combobox design pattern.

-

Textbox

+

Closed Combobox

@@ -101,26 +100,18 @@

Textbox

- - - - - + @@ -145,9 +136,9 @@

Listbox Popup

@@ -156,7 +147,7 @@

Listbox Popup

@@ -165,8 +156,7 @@

Listbox Popup

@@ -175,43 +165,30 @@

Listbox Popup

- - - - - - - - - + - +
Up Arrow
    -
  • First opens the listbox if it is not already displayed and then moves visual focus to the last option.
  • +
  • First opens the listbox if it is not already displayed and then moves visual focus to the first option.
  • DOM focus remains on the textbox.
Enter -
    -
  • Sets the textbox value to the content of the selected option.
  • -
  • Closes the listbox if it is displayed.
  • -
-
Standard single line text editing keysPrintable Characters
    -
  • Keys used for cursor movement and text manipulation, such as Delete and Shift + Right Arrow.
  • -
  • An HTML input with type="text" is used for the textbox so the browser will provide platform-specific editing keys.
  • +
  • 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
Enter
    -
  • Sets the textbox value to the content of the focused option in the listbox.
  • +
  • Sets the value to the content of the focused option in the listbox.
  • Closes the listbox.
  • -
  • Sets visual focus on the textbox.
  • +
  • Sets visual focus on the combobox.
  • Closes the listbox.
  • -
  • Sets visual focus on the textbox.
  • +
  • Sets visual focus on the combobox.
  • Moves visual focus to the next option.
  • -
  • If visual focus is on the last option, moves visual focus to the first option.
  • -
  • Note: This wrapping behavior is useful when Home and End move the editing cursor as described below.
  • +
  • If visual focus is on the last option, visual focus does not move.
  • Moves visual focus to the previous option.
  • -
  • If visual focus is on the first option, moves visual focus to the last option.
  • -
  • Note: This wrapping behavior is useful when Home and End move the editing cursor as described below.
  • +
  • If visual focus is on the first option, visual focus does not move.
Right ArrowMoves visual focus to the textbox and moves the editing cursor one character to the right.
Left ArrowMoves visual focus to the textbox and moves the editing cursor one character to the left.
HomeMoves visual focus to the textbox and places the editing cursor at the beginning of the field.Moves visual focus to the first option.
EndMoves visual focus to the textbox and places the editing cursor at the end of the field.Moves visual focus to the last option.
Printable Characters
    -
  • Moves visual focus to the textbox.
  • -
  • Types the character in the textbox.
  • -
  • Options in the listbox are not filtered based on the characters in the textbox.
  • +
  • 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
-

Button

-

- The button has been removed from the tab sequence of the page, but is still important to assistive technologies for mobile devices that use touch events to open the list of options. -

@@ -221,7 +198,7 @@

Role, Property, State, and Tabindex Attributes

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.

-

Textbox

+

Combobox

@@ -237,23 +214,15 @@

Textbox

combobox - + - - - - - - - + @@ -261,7 +230,7 @@

Textbox

- + @@ -269,28 +238,15 @@

Textbox

- + - - - - - - - + - - - - - - - + - +
input[type="text"]div Identifies the input as a combobox.
- aria-autocomplete="none" - input[type="text"]Indicates that the suggestions in the combobox popup are not values that complete the current textbox input.
aria-controls="#IDREF" input[type="text"]div Identifies the element that serves as the popup.
aria-expanded="false" input[type="text"]div Indicates that the popup element is not displayed.
aria-expanded="true" input[type="text"]div Indicates that the popup element is displayed.
- id="string" - input[type="text"] -
    -
  • Referenced by for attribute of label element to provide an accessible name.
  • -
  • Recommended naming method for HTML input elements because clicking label focuses input.
  • -
-
aria-activedescendant="IDREF" input[type="text"]div
  • When an option in the listbox is visually indicated as having keyboard focus, refers to that option.
  • @@ -322,24 +278,16 @@

    Listbox Popup

- ul + div Identifies the ul element as a listbox.
- aria-label="Previous Searches" - ulProvides a label for the listbox.Identifies the element as a listbox.
option lidiv
  • Identifies the element as a listbox option.
  • @@ -362,59 +310,6 @@

    Listbox Popup

-

Button

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RoleAttributeElementUsage
- tabindex="-1" - buttonRemoves the button from the tab sequence of the page, since it's keyboard function is redundant with the keyboard operation of the textbox to open the listbox.
- aria-label="Previous Searches" - buttonProvides a label for the button.
- aria-controls="#IDREF" - buttonIdentifies the element that serves as the popup.
- aria-expanded="false" - buttonIndicates that the popup element is not displayed.
- aria-expanded="true" - buttonIndicates that the popup element is displayed.
@@ -422,11 +317,11 @@

Javascript and CSS Source Code

diff --git a/examples/combobox/css/select-only.css b/examples/combobox/css/select-only.css index 856d423803..6eeaf22e7a 100644 --- a/examples/combobox/css/select-only.css +++ b/examples/combobox/css/select-only.css @@ -12,8 +12,8 @@ } .combo::after { - border-bottom: 2px solid rgba(0,0,0,.5); - border-right: 2px solid rgba(0,0,0,.5); + 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; @@ -27,7 +27,7 @@ .combo-input { background-color: #f5f5f5; - border: 2px solid rgba(0,0,0,.5); + border: 2px solid rgba(0, 0, 0, 0.75); border-radius: 4px; display: block; font-size: 1em; @@ -44,7 +44,7 @@ .combo-input:focus { border-color: #0067b8; box-shadow: 0 0 4px 2px #0067b8; - outline: 5px solid transparent; + outline: 4px solid transparent; } .combo-label { @@ -56,7 +56,7 @@ .combo-menu { background-color: #f5f5f5; - border: 1px solid rgba(0,0,0,.42); + border: 1px solid rgba(0, 0, 0, 0.75); border-radius: 0 0 4px 4px; display: none; max-height: 300px; @@ -76,17 +76,21 @@ padding: 10px 12px 12px; } -.combo-option.option-current, .combo-option:hover { - background-color: rgba(0,0,0,0.1); + background-color: rgba(0, 0, 0, 0.1); } -.combo-option.option-selected { +.combo-option.option-current { + outline: 3px solid #0067b8; + outline-offset: -3px; +} + +.combo-option[aria-selected="true"] { padding-right: 30px; position: relative; } -.combo-option.option-selected::after { +.combo-option[aria-selected="true"]::after { border-bottom: 2px solid #000; border-right: 2px solid #000; content: ''; @@ -96,55 +100,4 @@ top: 50%; transform: translate(0, -50%) rotate(45deg); width: 8px; -} - -/* multiselect list of selected options */ -.selected-options { - list-style-type: none; - margin: 0; - max-width: 400px; - padding: 0; -} - -.selected-options li { - display: inline-block; - margin-bottom: 5px; -} - -.remove-option { - background-color: #6200ee; - border: 1px solid #6200ee; - border-radius: 3px; - color: #fff; - font-size: 0.75em; - font-weight: bold; - margin-bottom: 6px; - margin-right: 6px; - padding: 0.25em 1.75em 0.25em 0.25em; - position: relative; -} - -.remove-option:focus { - border-color: #baa1dd; - box-shadow: 0 0 3px 1px #6200ee; - outline: 3px solid transparent; -} - -.remove-option::before, -.remove-option::after { - border-right: 2px solid #fff; - content: ""; - height: 1em; - right: 0.75em; - position: absolute; - top: 50%; - width: 0; -} - -.remove-option::before { - transform: translate(0, -50%) rotate(45deg); -} - -.remove-option::after { - transform: translate(0, -50%) rotate(-45deg); } \ No newline at end of file diff --git a/examples/combobox/js/select-only.js b/examples/combobox/js/select-only.js index 5b66962e73..1f83975754 100644 --- a/examples/combobox/js/select-only.js +++ b/examples/combobox/js/select-only.js @@ -1,4 +1,5 @@ -const MenuActions = { +// Save a list of named combobox actions, for future readability +const SelectActions = { Close: 0, CloseSelect: 1, First: 2, @@ -24,74 +25,82 @@ function filterOptions(options = [], filter, exclude = []) { }); } -// return a combobox action from a key press +// map a key press to an action function getActionFromKey(event, menuOpen) { const { key, altKey, ctrlKey, metaKey } = event; // handle opening when closed - if (!menuOpen && (key === 'ArrowDown' || key === 'Enter' || key === ' ')) { - return MenuActions.Open; + if (!menuOpen && (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ')) { + return SelectActions.Open; } - // handle keys when open - if (key === 'ArrowDown') { - return MenuActions.Next; - } - else if (key === 'ArrowUp') { - return MenuActions.Previous; - } - else if (key === 'Home') { - return MenuActions.First; - } - else if (key === 'End') { - return MenuActions.Last; - } - else if (key === 'Escape') { - return MenuActions.Close; - } - else if (key === 'Enter') { - return MenuActions.CloseSelect; + // handle typing characters when open or closed + if (key === 'Backspace' || key === 'Clear' || (key.length === 1 && !altKey && !ctrlKey && !metaKey)) { + return SelectActions.Type; } - else if (key === ' ') { - return MenuActions.Space; - } - else if (key === 'Backspace' || key === 'Clear' || (key.length === 1 && !altKey && !ctrlKey && !metaKey)) { - return MenuActions.Type; + + // handle keys when open + if (menuOpen) { + if (key === 'ArrowDown') { + return SelectActions.Next; + } + else if (key === 'ArrowUp') { + return SelectActions.Previous; + } + else if (key === 'Home') { + return SelectActions.First; + } + else if (key === 'End') { + return SelectActions.Last; + } + else if (key === 'Escape') { + return SelectActions.Close; + } + else if (key === 'Enter') { + return SelectActions.CloseSelect; + } + else if (key === ' ') { + return SelectActions.Space; + } } } -// get index of option that matches a string -// if the filter is multiple iterations of the same letter (e.g "aaa"), -// then return the nth match of the single letter -function getIndexByLetter(options, filter) { - const firstMatch = filterOptions(options, filter)[0]; +// 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(options, filter[0]); - const matchIndex = (filter.length - 1) % matches.length; - return options.indexOf(matches[matchIndex]); + const matches = filterOptions(orderedOptions, filter[0]); + return options.indexOf(matches[0]); } + + // if no matches, return -1 else { return -1; } } -// get updated option index -function getUpdatedIndex(current, max, action) { +// get an updated option index after performing an action +function getUpdatedIndex(currentIndex, maxIndex, action) { switch(action) { - case MenuActions.First: + case SelectActions.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); + case SelectActions.Last: + return maxIndex; + case SelectActions.Previous: + return Math.max(0, currentIndex - 1); + case SelectActions.Next: + return Math.min(maxIndex, currentIndex + 1); default: - return current; + return currentIndex; } } @@ -100,7 +109,8 @@ function isScrollable(element) { return element && element.clientHeight < element.scrollHeight; } -// ensure given child element is within the parent's visible scroll area +// 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; @@ -118,16 +128,16 @@ function maintainScrollVisibility(activeElement, scrollParent) { /* * 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.valueEl = this.comboEl.querySelector('span'); this.listboxEl = el.querySelector('[role=listbox]'); // data - this.idBase = this.comboEl.id; + this.idBase = this.comboEl.id || 'combo'; this.options = options; // state @@ -135,42 +145,58 @@ const Select = function(el, options = []) { this.open = false; this.searchString = ''; this.searchTimeout = null; + + // init + if (el && this.comboEl && this.listboxEl) { + this.init(); + } } Select.prototype.init = function() { - this.valueEl.innerHTML = this.options[0]; + // 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.updateMenuState(true)); this.comboEl.addEventListener('keydown', this.onComboKeyDown.bind(this)); + // create options this.options.map((option, 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 = option; - - optionEl.addEventListener('click', (event) => { - event.stopPropagation(); - this.onOptionClick(index); - }); - optionEl.addEventListener('mousedown', this.onOptionMouseDown.bind(this)); - + 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 = ''; - }, 1000); + }, 500); + // add most recent letter to saved search string this.searchString += char; return this.searchString; } @@ -182,53 +208,71 @@ Select.prototype.onComboKeyDown = function(event) { const action = getActionFromKey(event, this.open); switch(action) { - case MenuActions.Next: - case MenuActions.Last: - case MenuActions.First: - case MenuActions.Previous: + case SelectActions.Next: + case SelectActions.Last: + case SelectActions.First: + case SelectActions.Previous: event.preventDefault(); return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action)); - case MenuActions.CloseSelect: - case MenuActions.Space: + case SelectActions.CloseSelect: + case SelectActions.Space: event.preventDefault(); this.selectOption(this.activeIndex); // intentional fallthrough - case MenuActions.Close: + case SelectActions.Close: event.preventDefault(); return this.updateMenuState(false); - case MenuActions.Type: - this.updateMenuState(true); - var searchString = this.getSearchString(key); - return this.onOptionChange(Math.max(0, getIndexByLetter(this.options, searchString))); - case MenuActions.Open: + case SelectActions.Type: + return this.onComboType(key); + case SelectActions.Open: event.preventDefault(); return this.updateMenuState(true); } } 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.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); + } +} + Select.prototype.onOptionChange = function(index) { + // update state this.activeIndex = index; + + // update aria-activedescendant this.comboEl.setAttribute('aria-activedescendant', `${this.idBase}-${index}`); - // update active style + // 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); } @@ -241,14 +285,19 @@ Select.prototype.onOptionClick = function(index) { } 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) { - const selected = this.options[index]; - this.valueEl.innerHTML = selected; + // 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) => { @@ -258,21 +307,31 @@ Select.prototype.selectOption = function(index) { } 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'); - callFocus && this.comboEl.focus(); // update activedescendant - const activeID = open ? `${this.idBase}-${this.activeIndex}` : this.valueEl.id; + 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 selectEl = document.querySelector('.js-select'); const options = ['Apple', 'Banana', 'Blueberry', 'Boysenberry', 'Cherry', 'Durian', 'Eggplant', 'Fig', 'Grape', 'Guava', 'Huckleberry']; - const selectComponent = new Select(selectEl, options); - selectComponent.init(); + const selectEls = document.querySelectorAll('.js-select'); + + selectEls.forEach((el) => { + const selectComponent = new Select(el, options); + }); }); \ No newline at end of file From f8e4e7a5b83b619a35bbea00b80a50f043cd9eec Mon Sep 17 00:00:00 2001 From: Sarah Higley Date: Sun, 10 May 2020 19:07:20 -0700 Subject: [PATCH 03/12] add default option, alt+up, page up and down, and change to timeout --- examples/combobox/combobox-select-only.html | 2 +- examples/combobox/js/select-only.js | 75 ++++++++++++++------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/examples/combobox/combobox-select-only.html b/examples/combobox/combobox-select-only.html index 802b3522ce..2df86c197d 100644 --- a/examples/combobox/combobox-select-only.html +++ b/examples/combobox/combobox-select-only.html @@ -42,7 +42,7 @@

Select-Only Combobox Example

Example

- +
= 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) { @@ -328,7 +351,7 @@ Select.prototype.updateMenuState = function(open, callFocus = true) { // init select window.addEventListener('load', function () { - const options = ['Apple', 'Banana', 'Blueberry', 'Boysenberry', 'Cherry', 'Durian', 'Eggplant', 'Fig', 'Grape', 'Guava', 'Huckleberry']; + 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) => { From cbeb36d8f01f086f837ace26be3d5313f38a54e4 Mon Sep 17 00:00:00 2001 From: Sarah Higley Date: Sun, 31 May 2020 22:49:36 -0700 Subject: [PATCH 04/12] add links to select-only combo and listbox label --- aria-practices.html | 1 + .../combobox/combobox-autocomplete-both.html | 1 + .../combobox/combobox-autocomplete-list.html | 1 + .../combobox/combobox-autocomplete-none.html | 1 + examples/combobox/combobox-select-only.html | 2 +- examples/combobox/grid-combo.html | 1 + examples/combobox/js/select-only.js | 7 ++----- examples/index.html | 20 ++++++++++++++++++- 8 files changed, 27 insertions(+), 7 deletions(-) diff --git a/aria-practices.html b/aria-practices.html index a55add464a..7a6b53098a 100644 --- a/aria-practices.html +++ b/aria-practices.html @@ -790,6 +790,7 @@

Combobox

Examples

    +
  • 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/combobox-autocomplete-both.html b/examples/combobox/combobox-autocomplete-both.html index c3153415ef..09f1d6cbd3 100644 --- a/examples/combobox/combobox-autocomplete-both.html +++ b/examples/combobox/combobox-autocomplete-both.html @@ -39,6 +39,7 @@

    Editable Combobox With Both List and Inline 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 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.
    • 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-list.html b/examples/combobox/combobox-autocomplete-list.html index 3be610cd02..557518f36a 100644 --- a/examples/combobox/combobox-autocomplete-list.html +++ b/examples/combobox/combobox-autocomplete-list.html @@ -39,6 +39,7 @@

      Editable Combobox With List 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 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 index 2df86c197d..ca4e75415d 100644 --- a/examples/combobox/combobox-select-only.html +++ b/examples/combobox/combobox-select-only.html @@ -56,7 +56,7 @@

          Example

          tabindex="0" >
-
+
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:

@@ -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

    -
    +
    From d00abe643253dafe1132cfe46c190cb7ca7e9291 Mon Sep 17 00:00:00 2001 From: Sarah Higley Date: Sun, 17 May 2020 13:53:27 -0700 Subject: [PATCH 06/12] update home and end opening behavior --- examples/combobox/combobox-select-only.html | 1 - examples/combobox/js/select-only.js | 62 +++++++++++---------- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/examples/combobox/combobox-select-only.html b/examples/combobox/combobox-select-only.html index bdd04ef851..4fa8aed74b 100644 --- a/examples/combobox/combobox-select-only.html +++ b/examples/combobox/combobox-select-only.html @@ -45,7 +45,6 @@

    Example