@n8d/htwoo-core
Version:
hTWOo is a Fluent Design Framework purely in HTML and CSS
363 lines (359 loc) • 15.6 kB
JavaScript
define(['exports'], (function (exports) { 'use strict';
// Improved from:
// 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
/**
* Initialize an accessible custom select element
* @param listItem - The custom select container element
*/
const ariaSelect = (listItem) => {
console.debug('ariaSelect', listItem);
// Get elements within the select component
const csSelector = listItem;
console.log('csSelector', csSelector);
const csInput = csSelector.querySelector('input');
if (!csInput) {
console.error('Input element not found within select component');
return;
}
const csList = csSelector.querySelector('ul');
if (!csList) {
console.error('List element not found within select component');
return;
}
const csOptions = csList.querySelectorAll('li.hoo-option');
if (!csOptions || csOptions.length === 0) {
console.warn('No options found within select component');
}
csSelector.querySelectorAll('svg');
// Status element to announce to screen readers
const csStatus = document.querySelector('#custom-select-status');
if (!csStatus) {
console.warn('Status element #custom-select-status not found');
}
// Convert NodeList to Array for easier manipulation
const aOptions = Array.from(csOptions);
// Create and set start point for the state tracker
let csState = "initial";
// Set up ARIA attributes for accessibility
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 status message for screen readers
if (csStatus) {
csStatus.textContent = csOptions.length + " options available. Arrow down to browse or start typing to filter.";
}
// Initial state
toggleList('Shut');
/**
* Toggle the visibility of the dropdown list
* @param whichWay - 'Open' or 'Shut'
*/
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');
}
}
/**
* Find the currently focused element
* @returns The element that currently has focus
*/
function findFocus() {
return document.activeElement;
}
/**
* Move focus between elements
* @param fromHere - Current element with focus
* @param toThere - Direction to move focus ('input', 'forward', or 'back')
*/
function moveFocus(fromHere, toThere) {
// Get currently visible options
const aCurrentOptions = aOptions.filter((option) => {
return option.style.display === '';
});
// Don't move if no options are visible
if (aCurrentOptions.length === 0) {
return;
}
if (toThere === 'input') {
csInput.focus();
return;
}
// Handle focus movement based on starting point
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];
if (nextOne)
nextOne.focus();
}
else if (toThere === 'back' && whichOne > 0) {
const previousOne = aCurrentOptions[whichOne - 1];
if (previousOne)
previousOne.focus();
}
else { // if whichOne = 0
csInput.focus();
}
break;
}
}
/**
* Filter options based on input value
*/
function doFilter() {
const terms = csInput.value;
const aFilteredOptions = aOptions.filter((option) => {
return option.innerText.toUpperCase().startsWith(terms.toUpperCase());
});
console.debug(aFilteredOptions);
// Hide all options first
csOptions.forEach(option => option.style.display = "none");
// Show only filtered options
aFilteredOptions.forEach((option) => {
option.style.display = "";
});
setState('filtered');
updateStatus(aFilteredOptions.length);
}
/**
* Update status message for screen readers
* @param howMany - Number of options currently available
*/
function updateStatus(howMany) {
if (csStatus) {
csStatus.textContent = howMany + " options available.";
}
}
/**
* Make a selection from the dropdown
* @param whichOption - The selected option element
*/
function makeChoice(whichOption) {
const optionValue = whichOption.dataset.value;
if (optionValue) {
csInput.value = optionValue;
}
moveFocus(document.activeElement, 'input');
}
/**
* Update the component state
* @param newState - The new state to set
*/
function setState(newState) {
csState = newState;
}
/**
* Handle keyboard navigation and actions
* @param whichKey - The key that was pressed
*/
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 = opened or filtered, set state to initial
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 is opened or filtered, move focus forward
toggleList('Open');
moveFocus(currentFocus, 'forward');
}
break;
case 'ArrowUp':
if (csState === 'initial' || csState === 'closed') {
// if state = initial or closed, set state to opened and moveFocus to last
toggleList('Open');
moveFocus(csInput, 'back');
setState('opened');
}
else {
// if state is opened or filtered, move focus back
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;
}
}
// Event Listeners
// Handle clicks on the custom select
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;
}
});
// Handle keyboard events
csSelector.addEventListener('keyup', function (e) {
doKeyAction(e.key);
});
// Close the select when clicking outside
document.addEventListener('click', function (e) {
const target = e.target;
if (!target.closest('.hoo-select')) {
// click outside of the custom group
toggleList('Shut');
setState('initial');
}
});
};
/**
* Update visibility of optgroups based on their children's visibility
*/
function updateOptgroupVisibility() {
const optgroups = document.querySelectorAll('.hoo-optgroup');
optgroups.forEach(optgroup => {
const options = optgroup.querySelectorAll('.hoo-option');
const hasVisibleOption = Array.from(options).some(option => option.style.display !== 'none');
// Hide or show the optgroup based on visibility of its options
optgroup.style.display = hasVisibleOption ? '' : 'none';
});
}
// Run initially to set visibility
updateOptgroupVisibility();
// Setup mutation observer to handle dynamic changes
const selectDropdown = document.querySelector('.hoo-select-dropdown');
if (selectDropdown) {
const observer = new MutationObserver(() => {
updateOptgroupVisibility();
});
observer.observe(selectDropdown, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style'] // Monitor only style changes
});
}
exports.ariaSelect = ariaSelect;
}));