<div>
<h1>Custom Dropdown</h1>
<div class="container">
<label for="select">Custom Select</label>
<button
role="combobox"
id="select"
value="Select"
aria-controls="listbox"
aria-haspopup="listbox"
tabindex="0"
aria-expanded="false">
Select
</button>
<div id="announcement" aria-live="assertive" role="alert"></div>
<ul role="listbox" id="listbox">
<li role="option">Mecury</li>
<li role="option">Eggs</li>
<li role="option">Venus</li>
<li role="option">Earth</li>
<li role="option">Mars</li>
<li role="option">Jupiter</li>
<li role="option">Saturn</li>
<li role="option">Elephant</li>
<li role="option">Uranus</li>
<li role="option">Neptune</li>
<li role="option">Eleplast</li>
<li role="option">Pluto (I'm a planet)</li>
</ul>
</div>
<div class="container">
<label for="select2">Default Select</label>
<select id="select2">
<option>Mecury</option>
<option>Venus</option>
<option>Earth</option>
<option>Mars</option>
<option>Jupiter</option>
<option>Saturn</option>
<option>Uranus</option>
<option>Neptune</option>
<option>Eggsd</option>
<option>Pluto (I'm a planet)</option>
</select>
</div>
</div>
body {
display: grid;
place-content: center;
height: 100vh;
margin: 0;
background: #0a405b;
color: #fff;
font-family: "Montserrat", sans-serif;
font-size: 20px;
}
* {
margin: 0;
box-sizing: border-box;
}
.container {
margin: 1.2rem 0;
position: relative;
#announcement {
opacity: 0;
pointer-events: none;
}
label {
display: block;
padding: .7rem .8rem;
width: 65%;
margin: 0 auto;
text-align: left;
font-size: .75rem;
}
select,
button,
ul{
display: block;
padding: .7rem .8rem;
width: 60%;
margin: 0 auto;
text-align: left;
background: white;
border: 0;
font-size: 1rem;
}
button{
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
&::before {
font-family: "Font Awesome 5 Free";
content: "\f107";
vertical-align: middle;
font-weight: 900;
position: absolute;
right: .8rem;
}
&:focus-visible {
outline: 0;
// outline-offset: -3px;
box-shadow: 0 0 5px 2px rgba(251, 146, 60, 0.7) inset;
}
}
ul {
color: #3f403b;
position: absolute;
left: 0;
right: 0;
top: 4.8rem;
max-height: 10rem;
overflow-y: auto;
list-style-type: none;
padding: 0;
margin-top: .1rem;
opacity: 0;
transform: scale(1,0);
transform-origin: top left;
transition: all .3s ease-in;
pointer-events: none;
z-index: 2;
&.active {
opacity: 1;
transform: scale(1,1);
pointer-events: auto;
}
li {
padding: .6rem .5rem;
border-top: 1px solid #e6e6e6;
cursor: pointer;
transition: all .3s ease-in;
position: relative;
&::before {
font-family: "Font Awesome 5 Free";
content: "\f00c";
vertical-align: middle;
font-weight: 900;
position: absolute;
right: .8rem;
opacity: 0;
transition: opacity .300s ease-out;
}
&:hover, &.current {
background: #e6e6e6;
}
&.active {
// border: 2px solid;
box-shadow: 0 0 0 2px rgba(251, 146, 60, 0.7);
}
&.active::before {
opacity: 1;
}
}
}
}
View Compiled
const elements = {
button: document.querySelector('[role="combobox"]'),
dropdown: document.querySelector('[role="listbox"]'),
options: document.querySelectorAll('[role="option"]'),
announcement: document.getElementById('announcement'),
};
let isDropdownOpen = false;
let currentOptionIndex = 0;
let lastTypedChar = '';
let lastMatchingIndex = 0;
const toggleDropdown = () => {
elements.dropdown.classList.toggle('active');
isDropdownOpen = !isDropdownOpen;
elements.button.setAttribute('aria-expanded', isDropdownOpen.toString());
if (isDropdownOpen) {
focusCurrentOption();
} else {
elements.button.focus();
}
};
const handleKeyPress = (event) => {
event.preventDefault();
const { key } = event;
const openKeys = ['ArrowDown', 'ArrowUp', 'Enter', ' '];
if (!isDropdownOpen && openKeys.includes(key)) {
toggleDropdown();
} else if (isDropdownOpen) {
switch (key) {
case 'Escape':
toggleDropdown();
break;
case 'ArrowDown':
moveFocusDown();
break;
case 'ArrowUp':
moveFocusUp();
break;
case 'Enter':
case ' ':
selectCurrentOption();
break;
default:
// Handle alphanumeric key presses for mini-search
handleAlphanumericKeyPress(key);
break;
}
}
};
const handleDocumentInteraction = (event) => {
const isClickInsideButton = elements.button.contains(event.target);
const isClickInsideDropdown = elements.dropdown.contains(event.target);
if (isClickInsideButton || (!isClickInsideDropdown && isDropdownOpen)) {
toggleDropdown();
}
// Check if the click is on an option
const clickedOption = event.target.closest('[role="option"]');
if (clickedOption) {
selectOptionByElement(clickedOption);
}
};
const moveFocusDown = () => {
if (currentOptionIndex < elements.options.length - 1) {
currentOptionIndex++;
} else {
currentOptionIndex = 0;
}
focusCurrentOption();
};
const moveFocusUp = () => {
if (currentOptionIndex > 0) {
currentOptionIndex--;
} else {
currentOptionIndex = elements.options.length - 1;
}
focusCurrentOption();
};
const focusCurrentOption = () => {
const currentOption = elements.options[currentOptionIndex];
const optionLabel = currentOption.textContent;
currentOption.classList.add('current');
currentOption.focus();
// Scroll the current option into view
currentOption.scrollIntoView({
block: 'nearest',
});
elements.options.forEach((option, index) => {
if (option !== currentOption) {
option.classList.remove('current');
}
});
announceOption(`You're currently focused on ${optionLabel}`); // Announce the selected option within a delayed period
};
const selectCurrentOption = () => {
const selectedOption = elements.options[currentOptionIndex];
selectOptionByElement(selectedOption);
};
const selectOptionByElement = (optionElement) => {
const optionValue = optionElement.textContent;
elements.button.textContent = optionValue;
elements.options.forEach(option => {
option.classList.remove('active');
option.setAttribute('aria-selected', 'false');
});
optionElement.classList.add('active');
optionElement.setAttribute('aria-selected', 'true');
toggleDropdown();
announceOption(optionValue); // Announce the selected option
};
const handleAlphanumericKeyPress = (key) => {
const typedChar = key.toLowerCase();
if (lastTypedChar !== typedChar) {
lastMatchingIndex = 0;
}
const matchingOptions = Array.from(elements.options).filter((option) =>
option.textContent.toLowerCase().startsWith(typedChar)
);
if (matchingOptions.length) {
if (lastMatchingIndex === matchingOptions.length) {
lastMatchingIndex = 0;
}
let value = matchingOptions[lastMatchingIndex]
const index = Array.from(elements.options).indexOf(value);
currentOptionIndex = index;
focusCurrentOption();
lastMatchingIndex += 1;
}
lastTypedChar = typedChar;
};
const announceOption = (text) => {
elements.announcement.textContent = text;
elements.announcement.setAttribute('aria-live', 'assertive');
setTimeout(() => {
elements.announcement.textContent = '';
elements.announcement.setAttribute('aria-live', 'off');
}, 1000); // Announce and clear after 1 second (adjust as needed)
};
elements.button.addEventListener('keydown', handleKeyPress);
document.addEventListener('click', handleDocumentInteraction);
This Pen doesn't use any external JavaScript resources.