Autocomplete with contenteditable

by Ciprian Popescu

HTML

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

<p>As seen on <a href="https://getbutterfly.com/dropdown-autocomplete-with-contenteditable-fields/">getButterfly</a>.</p>

CSS

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

JavaScript

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...