Dropdown Autocomplete with contenteditable Fields

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;
}

on in JavaScript Arrays & Objects, JavaScript DOM | Last modified on

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *