I am implementing the following combobox: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
As you can notice in the example code from the link above, when I click with my mouse on the combobox, there is a blue focus around the selected option in the listbox.
How do I remove this focus when interacting with the combobox using mouse? I would like to keep the focus when interacting with the combobox using keyboard only.
/*
* 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 element is visible in browser view port
function isElementInView(element) {
var bounding = element.getBoundingClientRect();
return (
bounding.top >= 0 &&
bounding.left >= 0 &&
bounding.bottom <=
(window.innerHeight || document.documentElement.clientHeight) &&
bounding.right <=
(window.innerWidth || document.documentElement.clientWidth)
);
}
// 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.listboxEl.addEventListener('focusout', 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(event) {
// do nothing if relatedTarget is contained within listboxEl
if (this.listboxEl.contains(event.relatedTarget)) {
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);
}
// ensure the new option is visible on screen
// ensure the new option is in view
if (!isElementInView(options[index])) {
options[index].scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
};
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);
if (activeID === '' && !isElementInView(this.comboEl)) {
this.comboEl.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}
// 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);
});
});
.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 rgb(0 0 0 / 75%);
border-right: 2px solid rgb(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 rgb(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 rgb(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: rgb(0 0 0 / 10%);
}
.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;
}
<label id="combo1-label" class="combo-label">Favorite Fruit</label>
<div class="combo js-select">
<div aria-controls="listbox1" aria-expanded="false" aria-haspopup="listbox" aria-labelledby="combo1-label" id="combo1" class="combo-input" role="combobox" tabindex="0"></div>
<div class="combo-menu" role="listbox" id="listbox1" aria-labelledby="combo1-label" tabindex="-1">
</div>
</div>
from Remove focus from listbox options on mouse click only
No comments:
Post a Comment