138 lines
3.0 KiB
JavaScript
138 lines
3.0 KiB
JavaScript
function autocomp(el, options = {}) {
|
|
const opt = {
|
|
onQuery: null, onNavigate: null, onSelect: null, onRender: null, debounce: 100, autoSelect: true,...options
|
|
};
|
|
|
|
let box, cur = opt.autoSelect ? 0 : -1, items = [], val, req;
|
|
|
|
// Disable browser's default autocomplete behaviour on the input.
|
|
el.autocomplete = "off";
|
|
|
|
// Attach all the events required for the interactions in one go.
|
|
["input", "keydown", "blur"].forEach(k => el.addEventListener(k, handleEvent));
|
|
|
|
function handleEvent(e) {
|
|
if (e.type === "keydown" && !handleKeydown(e)) {
|
|
return;
|
|
}
|
|
|
|
if (e.type === "blur") {
|
|
return destroy();
|
|
}
|
|
|
|
const newVal = e.target.value;
|
|
if (!newVal) {
|
|
destroy();
|
|
val = null;
|
|
return;
|
|
}
|
|
|
|
if (newVal === val && box) {
|
|
return;
|
|
}
|
|
val = newVal;
|
|
|
|
// Clear (debounce) any existing pending requests and queue
|
|
// the next search request.
|
|
clearTimeout(req);
|
|
req = setTimeout(query, opt.debounce);
|
|
}
|
|
|
|
function handleKeydown(e) {
|
|
if (!box) {
|
|
return e.keyCode === 38 || e.keyCode === 40
|
|
}
|
|
|
|
switch (e.keyCode) {
|
|
case 38: return navigate(-1, e); // Up arrow.
|
|
case 40: return navigate(1, e); // Down arrow
|
|
case 9: // Tab
|
|
case 13: // Enter
|
|
e.preventDefault();
|
|
select(cur);
|
|
destroy();
|
|
return;
|
|
case 27: // Escape.
|
|
destroy();
|
|
return;
|
|
}
|
|
}
|
|
|
|
async function query() {
|
|
if (!val) {
|
|
return;
|
|
}
|
|
|
|
items = await opt.onQuery(val);
|
|
if (!items.length) {
|
|
return destroy();
|
|
}
|
|
|
|
if (!box) {
|
|
createBox();
|
|
}
|
|
|
|
renderResults();
|
|
}
|
|
|
|
function createBox() {
|
|
box = document.createElement("div");
|
|
Object.assign(box.style, {
|
|
width: window.getComputedStyle(el).width,
|
|
position: "absolute",
|
|
left: `${el.offsetLeft}px`,
|
|
top: `${el.offsetTop + el.offsetHeight}px`
|
|
});
|
|
|
|
box.classList.add("autocomp");
|
|
el.parentNode.insertBefore(box, el.nextSibling);
|
|
}
|
|
|
|
function renderResults() {
|
|
box.innerHTML = "";
|
|
items.forEach((item, idx) => {
|
|
const div = document.createElement("div");
|
|
div.classList.add("autocomp-item");
|
|
|
|
// If there's a custom renderer callback, use it, else, simply insert the value/text as-is.
|
|
opt.onRender ? div.appendChild(opt.onRender(item)) : div.innerText = item;
|
|
if (idx === cur) {
|
|
div.classList.add("autocomp-sel");
|
|
}
|
|
|
|
div.addEventListener("mousedown", () => select(idx));
|
|
box.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function navigate(direction, e) {
|
|
e.preventDefault();
|
|
|
|
// Remove the previous item's highlight;
|
|
const prev = box.querySelector(`:nth-child(${cur + 1})`);
|
|
prev?.classList.remove("autocomp-sel");
|
|
|
|
// Increment the cursor and highlight the next item, cycled between [0, n].
|
|
cur = (cur + direction + items.length) % items.length;
|
|
box.querySelector(`:nth-child(${cur + 1})`).classList.add("autocomp-sel");
|
|
}
|
|
|
|
function select(idx) {
|
|
if (!opt.onSelect) {
|
|
return;
|
|
}
|
|
|
|
val = opt.onSelect(items[idx]);
|
|
el.value = val || items[idx];
|
|
}
|
|
|
|
function destroy() {
|
|
items = [];
|
|
cur = opt.autoSelect ? 0 : -1;
|
|
if (box) {
|
|
box.remove();
|
|
box = null;
|
|
}
|
|
}
|
|
}
|