Autocomplete for contenteditable
Fields Using Vanilla JavaScript
If you’ve ever needed to implement autocomplete functionality in a contenteditable
field or display a dropdown of possible values, you’re in luck. Previously, I achieved this using jQuery UI. While it served its purpose, I sought a more lightweight and dependency-free solution. Now, I’ve developed a version using pure vanilla JavaScript, eliminating the need for external libraries.โ
๐ The Challenge
Implementing autocomplete in a contenteditable
element presents unique challenges compared to standard input fields. Managing the caret position, handling user input, and displaying suggestion lists require careful handling to ensure a seamless user experience.โ
๐ ๏ธ The Solution
The vanilla JavaScript implementation addresses these challenges by:โ
- Monitoring User Input: Listening for input events to detect when the user types.
- Generating Suggestions: Filtering a predefined list of tags based on the user’s input.
- Displaying Suggestions: Showing a dropdown list positioned near the caret within the
contenteditable
element. - Navigating Suggestions: Allowing users to navigate the list using arrow keys and select a suggestion with the Enter key.
- Inserting Selections: Replacing the current word or inserting the selected suggestion at the caret position.โ
This approach ensures that the autocomplete functionality is both responsive and accessible, providing a user-friendly experience without relying on external dependencies.
<label for="MyText">Region:</label>
<div id="MyText" contenteditable="true" role="textbox" aria-autocomplete="list" aria-haspopup="true" aria-expanded="false" aria-owns="autocomplete-list" aria-activedescendant=""></div>
<ul id="autocomplete-list" class="autocomplete-list" role="listbox" hidden></ul>
const tags = [
'Alaska',
'Asia / Far East',
'Baltic Capitals / Northern Europe',
'Canary Islands',
'Caribbean',
'Cruise from Ireland',
'Dubai & The Emirates',
'Grand Voyages / Repositioning Cruises',
'Mediterranean',
'Northern Lights',
'Norwegian Fjords',
'South America',
'Transatlantic',
'UK & Ireland',
];
const startTyping = "Start typing...";
const inputDiv = document.getElementById('MyText');
const suggestionList = document.getElementById('autocomplete-list');
let currentFocus = -1;
// Function to get caret position
function getCaretCoordinates() {
const selection = window.getSelection();
if (selection.rangeCount === 0) return { x: 0, y: 0 };
const range = selection.getRangeAt(0).cloneRange();
range.collapse(false);
const rect = range.getBoundingClientRect();
return { x: rect.left, y: rect.bottom };
}
// Function to place caret at the end
function placeCaretAtEnd(el) {
el.focus();
if (typeof window.getSelection != "undefined" && typeof document.createRange != "undefined") {
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
}
}
// Function to close the suggestion list
function closeSuggestionList() {
suggestionList.innerHTML = '';
suggestionList.hidden = true;
inputDiv.setAttribute('aria-expanded', 'false');
currentFocus = -1;
}
// Function to add active class to suggestion
function addActive(items) {
if (!items) return false;
removeActive(items);
if (currentFocus >= items.length) currentFocus = 0;
if (currentFocus < 0) currentFocus = items.length - 1;
items[currentFocus].classList.add("active");
inputDiv.setAttribute('aria-activedescendant', items[currentFocus].id);
}
// Function to remove active class from suggestions
function removeActive(items) {
for (let item of items) {
item.classList.remove("active");
}
}
// Function to insert suggestion at caret
function insertSuggestionAtCaret(suggestion) {
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const textNode = document.createTextNode(suggestion);
range.insertNode(textNode);
range.setStartAfter(textNode);
range.setEndAfter(textNode);
selection.removeAllRanges();
selection.addRange(range);
}
// Event listener for input
inputDiv.addEventListener('input', function(e) {
const val = inputDiv.textContent.trim();
closeSuggestionList();
if (!val) return;
const matches = tags.filter(tag => tag.toLowerCase().includes(val.toLowerCase()));
if (matches.length === 0) return;
suggestionList.innerHTML = '';
matches.forEach((match, index) => {
const item = document.createElement('li');
item.textContent = match;
item.id = `autocomplete-item-${index}`;
item.setAttribute('role', 'option');
item.addEventListener('click', function() {
insertSuggestionAtCaret(match);
closeSuggestionList();
});
suggestionList.appendChild(item);
});
const coords = getCaretCoordinates();
suggestionList.style.left = coords.x + 'px';
suggestionList.style.top = coords.y + 'px';
suggestionList.hidden = false;
inputDiv.setAttribute('aria-expanded', 'true');
});
// Event listener for keydown
inputDiv.addEventListener('keydown', function(e) {
const items = suggestionList.getElementsByTagName('li');
if (e.key === 'ArrowDown') {
currentFocus++;
addActive(items);
e.preventDefault();
} else if (e.key === 'ArrowUp') {
currentFocus--;
addActive(items);
e.preventDefault();
} else if (e.key === 'Enter') {
e.preventDefault();
if (currentFocus > -1) {
if (items[currentFocus]) {
items[currentFocus].click();
}
}
} else if (e.key === 'Escape') {
closeSuggestionList();
}
});
// Event listener for clicks outside
document.addEventListener('click', function(e) {
if (e.target !== inputDiv && e.target.parentNode !== suggestionList) {
closeSuggestionList();
}
});
#MyText {
width: 60%;
background-color: #ECF0F1;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.15);
padding: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #24292e;
position: relative;
}
.autocomplete-list {
list-style: none;
margin: 0;
padding: 0;
background-color: #FFFFFF;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.15);
position: absolute;
z-index: 1000;
max-height: 200px;
overflow-y: auto;
width: 60%;
}
.autocomplete-list li {
padding: 4px 6px;
cursor: pointer;
}
.autocomplete-list li:hover,
.autocomplete-list li.active {
background-color: #34495E;
color: #FFFFFF;
}