@n8d/htwoo-core
Version:
hTWOo is a Fluent Design Framework purely in HTML and CSS
306 lines (284 loc) • 11.4 kB
JavaScript
define(['exports'], (function (exports) { 'use strict';
// Origin 1: https://24ways.org/2019/making-a-better-custom-select-element/
// Origin 2: https://css-tricks.com/making-a-better-custom-select-element/
// Code Pen: https://codepen.io/chriscoyier/pen/yLyyZrr
// SETUP
// /////////////////////////////////
// assign names to things we'll need to use more than once
const ariaSelect = (listItem) => {
// console.log('listItem',listItem);
const csSelector = listItem; // the input, svg and ul as a group
console.log('csSelector', csSelector);
const csInput = csSelector.querySelector('input');
// console.log('csInput', csInput);
const csList = csSelector.querySelector('ul');
// console.log('csList', csList);
const csOptions = csList.querySelectorAll('li');
// console.log('csOptions', csOptions);
csSelector.querySelectorAll('svg');
// console.log('csIcons', csIcons);
const csStatus = document.querySelector('#custom-select-status');
// console.log('csStatus', csStatus);
const aOptions = Array.from(csOptions);
// when JS is loaded, set up our starting point
// if JS fails to load, the custom select remains a plain text input
// create and set start point for the state tracker
let csState = "initial";
// inform assistive tech (screen readers) of the names & roles of the elements in our group
csSelector.setAttribute('role', 'combobox');
csSelector.setAttribute('aria-haspopup', 'listbox');
csSelector.setAttribute('aria-owns', 'custom-select-list'); // container owns the list...
csInput.setAttribute('aria-autocomplete', 'both');
csInput.setAttribute('aria-controls', 'custom-select-list'); // ...but the input controls it
csList.setAttribute('role', 'listbox');
csOptions.forEach((option) => {
option.setAttribute('role', 'option');
option.setAttribute('tabindex', "-1"); // make li elements keyboard focusable by script only
});
// set up a message to keep screen reader users informed of what the custom input is for/doing
csStatus.textContent = csOptions.length + " options available. Arrow down to browse or start typing to filter.";
// EVENTS
// /////////////////////////////////
csSelector.addEventListener('click', function (e) {
const currentFocus = findFocus();
switch (csState) {
case 'initial': // if state = initial, toggleOpen and set state to opened
toggleList('Open');
setState('opened');
break
case 'opened':
// if state = opened and focus on input, toggleShut and set state to initial
if (currentFocus === csInput) {
toggleList('Shut');
setState('initial');
} else if (currentFocus.tagName === 'LI') {
// if state = opened and focus on list, makeChoice, toggleShut and set state to closed
makeChoice(currentFocus);
toggleList('Shut');
setState('closed');
}
break
case 'filtered':
// if state = filtered and focus on list, makeChoice and set state to closed
if (currentFocus.tagName === 'LI') {
makeChoice(currentFocus);
toggleList('Shut');
setState('closed');
} // if state = filtered and focus on input, do nothing (wait for next user input)
break
case 'closed': // if state = closed, toggleOpen and set state to filtered? or opened?
toggleList('Open');
setState('filtered');
break
}
});
csSelector.addEventListener('keyup', function (e) {
doKeyAction(e.key);
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.hoo-select')) {
// click outside of the custom group
toggleList('Shut');
setState('initial');
}
});
// FUNCTIONS
// /////////////////////////////////
function toggleList(whichWay) {
if (whichWay === 'Open') {
csList.classList.remove('hidden-all');
csSelector.setAttribute('aria-expanded', 'true');
} else { // === 'Shut'
csList.classList.add('hidden-all');
csSelector.setAttribute('aria-expanded', 'false');
}
}
function findFocus() {
const focusPoint = document.activeElement;
return focusPoint
}
function moveFocus(fromHere, toThere) {
// grab the currently showing options, which might have been filtered
const aCurrentOptions = aOptions.filter(function (option) {
if (option.style.display === '') {
return true
}
});
// don't move if all options have been filtered out
if (aCurrentOptions.length === 0) {
return
}
if (toThere === 'input') {
csInput.focus();
}
// possible start points
switch (fromHere) {
case csInput:
if (toThere === 'forward') {
aCurrentOptions[0].focus();
} else if (toThere === 'back') {
aCurrentOptions[aCurrentOptions.length - 1].focus();
}
break
case csOptions[0]:
if (toThere === 'forward') {
aCurrentOptions[1].focus();
} else if (toThere === 'back') {
csInput.focus();
}
break
case csOptions[csOptions.length - 1]:
if (toThere === 'forward') {
aCurrentOptions[0].focus();
} else if (toThere === 'back') {
aCurrentOptions[aCurrentOptions.length - 2].focus();
}
break
default: // middle list or filtered items
const currentItem = findFocus();
const whichOne = aCurrentOptions.indexOf(currentItem);
if (toThere === 'forward') {
const nextOne = aCurrentOptions[whichOne + 1];
nextOne.focus();
} else if (toThere === 'back' && whichOne > 0) {
const previousOne = aCurrentOptions[whichOne - 1];
previousOne.focus();
} else { // if whichOne = 0
csInput.focus();
}
break
}
}
function doFilter() {
const terms = csInput.value;
const aFilteredOptions = aOptions.filter(function (option) {
if (option.innerText.toUpperCase().startsWith(terms.toUpperCase())) {
return true
}
});
csOptions.forEach(option => option.style.display = "none");
aFilteredOptions.forEach(function (option) {
option.style.display = "";
});
setState('filtered');
updateStatus(aFilteredOptions.length);
}
function updateStatus(howMany) {
csStatus.textContent = howMany + " options available.";
}
function makeChoice(whichOption) {
const optionValue = whichOption.dataset.value;
csInput.value = optionValue;
moveFocus(document.activeElement, 'input');
// update aria-selected, if using
}
function setState(newState) {
switch (newState) {
case 'initial':
csState = 'initial';
break
case 'opened':
csState = 'opened';
break
case 'filtered':
csState = 'filtered';
break
case 'closed':
csState = 'closed';
}
// console.log({csState})
}
function doKeyAction(whichKey) {
const currentFocus = findFocus();
switch (whichKey) {
case 'Enter':
if (csState === 'initial') {
// if state = initial, toggleOpen and set state to opened
toggleList('Open');
setState('opened');
} else if (csState === 'opened' && currentFocus.tagName === 'LI') {
// if state = opened and focus on list, makeChoice and set state to closed
makeChoice(currentFocus);
toggleList('Shut');
setState('closed');
} else if (csState === 'opened' && currentFocus === csInput) {
// if state = opened and focus on input, close it
toggleList('Shut');
setState('closed');
} else if (csState === 'filtered' && currentFocus.tagName === 'LI') {
// if state = filtered and focus on list, makeChoice and set state to closed
makeChoice(currentFocus);
toggleList('Shut');
setState('closed');
} else if (csState === 'filtered' && currentFocus === csInput) {
// if state = filtered and focus on input, set state to opened
toggleList('Open');
setState('opened');
} else { // i.e. csState is closed, or csState is opened/filtered but other focus point?
// if state = closed, set state to filtered? i.e. open but keep existing input?
toggleList('Open');
setState('filtered');
}
break
case 'Escape':
// if state = initial, do nothing
// if state = opened or filtered, set state to initial
// if state = closed, do nothing
if (csState === 'opened' || csState === 'filtered') {
toggleList('Shut');
setState('initial');
}
break
case 'ArrowDown':
if (csState === 'initial' || csState === 'closed') {
// if state = initial or closed, set state to opened and moveFocus to first
toggleList('Open');
moveFocus(csInput, 'forward');
setState('opened');
} else {
// if state = opened and focus on input, moveFocus to first
// if state = opened and focus on list, moveFocus to next/first
// if state = filtered and focus on input, moveFocus to first
// if state = filtered and focus on list, moveFocus to next/first
toggleList('Open');
moveFocus(currentFocus, 'forward');
}
break
case 'ArrowUp':
if (csState === 'initial' || csState === 'closed') {
// if state = initial, set state to opened and moveFocus to last
// if state = closed, set state to opened and moveFocus to last
toggleList('Open');
moveFocus(csInput, 'back');
setState('opened');
} else {
// if state = opened and focus on input, moveFocus to last
// if state = opened and focus on list, moveFocus to prev/last
// if state = filtered and focus on input, moveFocus to last
// if state = filtered and focus on list, moveFocus to prev/last
moveFocus(currentFocus, 'back');
}
break
default:
if (csState === 'initial') {
// if state = initial, toggle open, doFilter and set state to filtered
toggleList('Open');
doFilter();
setState('filtered');
} else if (csState === 'opened') {
// if state = opened, doFilter and set state to filtered
doFilter();
setState('filtered');
} else if (csState === 'closed') {
// if state = closed, doFilter and set state to filtered
doFilter();
setState('filtered');
} else { // already filtered
doFilter();
}
break
}
}
};
exports.ariaSelect = ariaSelect;
}));