A zillion years ago (Windows 98! Internet Explorer 6!) I had an HTML combo box solution on this page that has since become wholly outdated and unusable. Rather than deleting the page, here's a modern implementation created with the help of the Gemini AI. I hope it proves useful.
Here's an example, a combo box of fruits:
Selected Fruit Value: None
This implementation is a tad more bloated than what I had but I cannot argue with "it works". The one thing I'd tweak is the idea of restricting dropdown contents to match the currently entered text. Then again, if the dropdown contains a lot of options, this can actually prove useful, so I'm leaving it on.
Without further ado, here is the source code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pure JS Combo Box</title>
<style>
/* Basic styling for the combo box */
.combobox
{
position: relative;
display: inline-block;
font-family: sans-serif;
}
.combobox-input
{
padding: 8px 30px 8px 10px; /* Increased right padding for button */
border: 1px solid #ccc;
border-radius: 4px;
width: 200px; /* Adjust as needed */
box-sizing: border-box; /* Include padding and border in the element's total width and height */
vertical-align: middle; /* Align input and button nicely */
}
.combobox-input:focus
{
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
/* Style the dropdown button */
.combobox-button
{
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 25px; /* Width of the button */
background-color: #eee;
border: 1px solid #ccc;
border-left: none; /* Remove left border */
border-radius: 0 4px 4px 0; /* Match input corners */
cursor: pointer;
display: flex; /* Use flexbox for centering arrow */
align-items: center;
justify-content: center;
box-sizing: border-box;
color: #333;
}
.combobox-button:hover
{
background-color: #ddd;
}
/* Simple arrow using text character */
.combobox-button::after
{
content: '▼'; /* Downward arrow character */
font-size: 10px;
}
/* Adjust position slightly when input is focused */
.combobox-input:focus + .combobox-button
{
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
/* Optional: Remove shadow from the button itself if input has it */
/* box-shadow: none; */
}
.combobox-options
{
display: none; /* Hidden by default */
position: absolute;
border: 1px solid #ccc;
border-top: none;
border-radius: 0 0 4px 4px;
max-height: 150px; /* Limit height and make scrollable */
overflow-y: auto;
background-color: white;
width: 100%; /* Make options list span the full width of the container */
/* --- Ensure these are present --- */
left: 0; /* Align with the left edge of the container */
box-sizing: border-box; /* Include padding and border in the element's total width */
z-index: 1000; /* Ensure it appears above other elements */
margin-top: -1px; /* Overlap slightly with input border */
}
.combobox-options.visible
{
display: block;
}
.combobox-option
{
padding: 8px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.combobox-option:hover,
.combobox-option.highlighted
{ /* Style for hover and keyboard navigation highlight */
background-color: #f0f0f0;
}
.combobox-option.selected
{
background-color: #007bff;
color: white;
}
/* Style for options that are filtered out (optional) */
.combobox-option.hidden
{
display: none;
}
</style>
</head>
<body>
<h1>Custom Combo Box Example</h1>
<p>Select a Fruit: <span id="fruit-combobox-container">
<!-- The combo box will be generated here by JavaScript -->
</span></p>
<p>Selected Fruit Value: <span id="selected-fruit-value">None</span></p>
<script>
function createComboBox(container, optionsData, idPrefix, onSelectCallback)
{
// --- Create Elements ---
const wrapper = document.createElement('div');
wrapper.className = 'combobox';
wrapper.id = `${idPrefix}-wrapper`;
const input = document.createElement('input');
input.type = 'text';
input.className = 'combobox-input';
input.placeholder = 'Select or type...';
input.id = `${idPrefix}-input`;
input.setAttribute('autocomplete', 'off'); // Prevent browser autocomplete
// --- NEW: Create the dropdown button ---
const button = document.createElement('button');
button.type = 'button'; // Important for forms
button.className = 'combobox-button';
button.id = `${idPrefix}-button`;
button.setAttribute('aria-label', 'Toggle options'); // Accessibility
button.tabIndex = -1; // Prevent button from being tab-focusable itself
const optionsList = document.createElement('div');
optionsList.className = 'combobox-options';
optionsList.id = `${idPrefix}-options`;
// Store the actual selected value
let currentSelectedValue = null;
let currentHighlightedIndex = -1; // For keyboard navigation
// --- Populate Options ---
// (This part remains the same as before)
const optionElements = optionsData.map((option, index) =>
{
const optElement = document.createElement('div');
optElement.className = 'combobox-option';
optElement.textContent = option.text;
optElement.dataset.value = option.value; // Store value in data attribute
optElement.dataset.index = index; // Store original index
optElement.id = `${idPrefix}-option-${index}`;
// Click listener for each option
optElement.addEventListener('mousedown', (e) =>
{ // Use mousedown to prevent blur before click registers
e.preventDefault(); // Prevent input losing focus
selectOption(optElement);
});
optionsList.appendChild(optElement);
return optElement; // Keep reference for filtering/navigation
});
// --- Append elements in order ---
wrapper.appendChild(input);
wrapper.appendChild(button); // Add button next to input
wrapper.appendChild(optionsList);
container.appendChild(wrapper);
// --- Event Listeners ---
// Show options on input focus or click
input.addEventListener('focus', showOptions);
input.addEventListener('click', showOptions); // Handle case where it already has focus
// Filter options on input typing
input.addEventListener('input', handleInput);
// Hide options on clicking outside (Check if click was on button)
document.addEventListener('click', (e) =>
{
// Check if the click is outside the wrapper AND not on the button itself
if (!wrapper.contains(e.target))
{
hideOptions();
}
});
// Keyboard Navigation (remains the same)
input.addEventListener('keydown', handleKeyDown);
// --- NEW: Button click listener ---
button.addEventListener('click', (e) =>
{
e.stopPropagation(); // Prevent document click listener from firing
// Toggle options list visibility
if (optionsList.classList.contains('visible'))
{
hideOptions();
}
else
{
input.focus(); // Focus input first to ensure keyboard nav works
showOptions();
}
});
// --- Helper Functions ---
function showOptions()
{
// If input has value, filter before showing
if(input.value)
{
filterOptions();
}
else
{
// If input is empty, show all options
optionElements.forEach(opt => opt.classList.remove('hidden'));
}
// Only show if there are visible options or input is empty
const anyVisible = optionElements.some(opt => !opt.classList.contains('hidden'));
if (anyVisible || !input.value)
{
optionsList.classList.add('visible');
}
else
{
optionsList.classList.remove('visible'); // Hide if filter results in no options
}
resetHighlight();
}
function hideOptions()
{
optionsList.classList.remove('visible');
resetHighlight();
// Optional: If text doesn't match a selected value, clear or reset
const matchingOption = optionElements.find(opt =>
opt.textContent.toLowerCase() === input.value.toLowerCase() &&
!opt.classList.contains('hidden'));
if (!matchingOption && currentSelectedValue)
{
// If user typed something invalid after selecting, revert to last valid selection
const selectedOpt = optionElements.find(opt => opt.dataset.value === currentSelectedValue);
input.value = selectedOpt ? selectedOpt.textContent : '';
}
else
if (!matchingOption && !currentSelectedValue)
{
// Or clear if nothing valid was ever selected
// input.value = ''; // Decide desired behavior
}
}
function filterOptions()
{
const filterText = input.value.toLowerCase();
let visibleOptionsExist = false;
optionElements.forEach(optElement =>
{
const optionText = optElement.textContent.toLowerCase();
if (optionText.includes(filterText))
{
optElement.classList.remove('hidden');
visibleOptionsExist = true;
}
else
{
optElement.classList.add('hidden');
}
});
// If input has text, ensure the dropdown stays visible if there are matches
if (filterText && visibleOptionsExist)
{
optionsList.classList.add('visible');
}
else
if (!visibleOptionsExist)
{
optionsList.classList.remove('visible'); // Hide if no matches
}
}
function handleInput()
{
// Reset selection when user types
if (currentSelectedValue)
{
const selectedOpt = optionElements.find(opt => opt.dataset.value === currentSelectedValue);
if(selectedOpt) selectedOpt.classList.remove('selected');
currentSelectedValue = null;
updateSelectedDisplay(null);
}
filterOptions();
resetHighlight(); // Reset keyboard highlight
// Don't automatically *hide* here, filterOptions handles visibility based on results
// Only ensure it *becomes* visible if typing yields results and it was hidden
const anyVisible = optionElements.some(opt => !opt.classList.contains('hidden'));
if (input.value && anyVisible && !optionsList.classList.contains('visible'))
{
optionsList.classList.add('visible');
}
}
function selectOption(optionElement)
{
if (!optionElement || optionElement.classList.contains('hidden')) return;
input.value = optionElement.textContent;
currentSelectedValue = optionElement.dataset.value;
// Update visual selection state
optionElements.forEach(opt => opt.classList.remove('selected'));
optionElement.classList.add('selected');
hideOptions();
updateSelectedDisplay(currentSelectedValue); // Call the callback
// No need to explicitly focus input here, it likely still has focus or lost it naturally
}
// handleKeyDown, highlightNextOption, highlightPreviousOption,
// updateHighlight, resetHighlight, updateSelectedDisplay functions remain the same
// ... (include the keyboard navigation functions from the previous version here) ...
function handleKeyDown(e)
{
const visibleOptions = optionElements.filter(opt => !opt.classList.contains('hidden'));
// Don't act if list hidden unless ArrowDown/Up/Enter to open/validate
const isOptionsVisible = optionsList.classList.contains('visible');
switch (e.key)
{
case 'ArrowDown':
e.preventDefault(); // Prevent cursor moving in input
if (!isOptionsVisible)
{
showOptions(); // Show all options if closed
}
else if (visibleOptions.length > 0)
{
highlightNextOption(visibleOptions);
}
break;
case 'ArrowUp':
e.preventDefault(); // Prevent cursor moving in input
if (!isOptionsVisible)
{
showOptions(); // Show all options if closed
}
else
if (visibleOptions.length > 0)
{
highlightPreviousOption(visibleOptions);
}
break;
case 'Enter':
e.preventDefault(); // Prevent form submission
if (isOptionsVisible && currentHighlightedIndex !== -1 && visibleOptions.length > 0)
{
selectOption(visibleOptions[currentHighlightedIndex]);
}
else
{
// If options not visible OR no item highlighted, try to match current input text exactly
const exactMatch = optionsData.find(opt => opt.text.toLowerCase() === input.value.toLowerCase());
if (exactMatch)
{
// Find the corresponding element to pass to selectOption
const exactMatchElement = optionElements.find(el => el.dataset.value === exactMatch.value);
if (exactMatchElement)
{
selectOption(exactMatchElement);
}
else
{
hideOptions(); // Hide if somehow element not found
}
}
else
{
// Otherwise, just hide the list if it was open
hideOptions();
}
}
break;
case 'Escape':
e.preventDefault(); // Prevent potential browser actions
if (isOptionsVisible)
{
hideOptions();
// Revert input to last selected value if Escape is pressed
const selectedOpt = optionElements.find(opt => opt.dataset.value === currentSelectedValue);
input.value = selectedOpt ? selectedOpt.textContent : '';
}
break;
case 'Tab':
hideOptions(); // Hide options when tabbing away
break;
default:
// When typing other keys, reset highlight as list content changes
resetHighlight();
}
}
function highlightNextOption(visibleOptions)
{
resetHighlight(false); // Clear previous highlight visually
currentHighlightedIndex++;
if (currentHighlightedIndex >= visibleOptions.length)
{
currentHighlightedIndex = 0; // Wrap around
}
updateHighlight(visibleOptions);
}
function highlightPreviousOption(visibleOptions)
{
resetHighlight(false); // Clear previous highlight visually
currentHighlightedIndex--;
if (currentHighlightedIndex < 0)
{
currentHighlightedIndex = visibleOptions.length - 1; // Wrap around
}
updateHighlight(visibleOptions);
}
function updateHighlight(visibleOptions)
{
if (currentHighlightedIndex >= 0 && currentHighlightedIndex < visibleOptions.length)
{
const highlightedOption = visibleOptions[currentHighlightedIndex];
highlightedOption.classList.add('highlighted');
// Scroll into view if needed
highlightedOption.scrollIntoView({ block: 'nearest' });
}
}
function resetHighlight(resetIndex = true)
{
optionElements.forEach(opt => opt.classList.remove('highlighted'));
if (resetIndex)
{
currentHighlightedIndex = -1;
}
}
function updateSelectedDisplay(value)
{
if (onSelectCallback && typeof onSelectCallback === 'function')
{
onSelectCallback(value);
}
}
} // End of createComboBox function
// --- Data for the Combo Box ---
const fruitOptions =
[
{ value: 'apple', text: 'Apple' },
{ value: 'banana', text: 'Banana' },
{ value: 'blueberry', text: 'Blueberry' },
{ value: 'cherry', text: 'Cherry' },
{ value: 'cranberry', text: 'Cranberry' },
{ value: 'durian', text: 'Durian' },
{ value: 'elderberry', text: 'Elderberry' },
{ value: 'fig', text: 'Fig' },
{ value: 'grape', text: 'Grape' },
{ value: 'grapefruit', text: 'Grapefruit' },
{ value: 'kiwi', text: 'Kiwi' },
{ value: 'lemon', text: 'Lemon' },
{ value: 'lime', text: 'Lime' },
{ value: 'mango', text: 'Mango' },
{ value: 'orange', text: 'Orange' },
{ value: 'papaya', text: 'Papaya' },
{ value: 'peach', text: 'Peach' },
{ value: 'pear', text: 'Pear' },
{ value: 'pineapple', text: 'Pineapple' },
{ value: 'plum', text: 'Plum' },
{ value: 'raspberry', text: 'Raspberry' },
{ value: 'strawberry', text: 'Strawberry' },
{ value: 'watermelon', text: 'Watermelon' }
];
// --- Initialize the Combo Box ---
window.onload = function()
{
createComboBox(document.getElementById('fruit-combobox-container'), fruitOptions, 'fruit-combo', (selectedValue) =>
{
// Callback function to update display when a value is selected
document.getElementById('selected-fruit-value').textContent = selectedValue || 'None';
console.log('Selected value:', selectedValue);
});
}
</script>
</body>
</html>