mirror of
https://github.com/itflow-org/itflow
synced 2026-08-24 08:25:14 +00:00
205 lines
6.8 KiB
JavaScript
205 lines
6.8 KiB
JavaScript
/**
|
|
* Minimal autocomplete, replacing jQuery UI's.
|
|
*
|
|
* itflowAutocomplete(input, {
|
|
* source: array of items, or fn(term) -> array
|
|
* minLength: default 1
|
|
* maxItems: cap on rendered results, default 50
|
|
* match: fn(item, term) -> bool (default: substring over item.label)
|
|
* render: fn(item) -> HTML string (default: escaped item.label)
|
|
* onSelect: fn(item)
|
|
* })
|
|
*
|
|
* Keyboard: up/down to move, Enter to pick, Escape to dismiss.
|
|
* The menu is capped to the space actually available in the viewport and
|
|
* scrolls internally, and flips above the input when there is more room there.
|
|
*/
|
|
function itflowEscapeHtml(value) {
|
|
var div = document.createElement('div');
|
|
div.textContent = value == null ? '' : String(value);
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function itflowAutocomplete(input, options) {
|
|
if (!input || input.itflowAutocomplete) {
|
|
return;
|
|
}
|
|
var opts = options || {};
|
|
var minLength = opts.minLength == null ? 1 : opts.minLength;
|
|
var maxItems = opts.maxItems == null ? 50 : opts.maxItems;
|
|
var GAP = 4; // breathing room against the input
|
|
var MARGIN = 8; // never touch the viewport edge
|
|
var MIN_HEIGHT = 120;
|
|
var items = [];
|
|
var active = -1;
|
|
|
|
var menu = document.createElement('div');
|
|
menu.className = 'itflow-ac-menu dropdown-menu p-0';
|
|
menu.setAttribute('role', 'listbox');
|
|
document.body.appendChild(menu);
|
|
|
|
function defaultMatch(item, term) {
|
|
return String(item.label || '').toLowerCase().indexOf(term) !== -1;
|
|
}
|
|
|
|
function close() {
|
|
menu.classList.remove('show');
|
|
menu.innerHTML = '';
|
|
active = -1;
|
|
input.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
/**
|
|
* Size and place the menu against whatever room the viewport actually has.
|
|
* Without this a long product list runs off the bottom of the page.
|
|
*/
|
|
function position() {
|
|
var r = input.getBoundingClientRect();
|
|
var below = window.innerHeight - r.bottom - GAP - MARGIN;
|
|
var above = r.top - GAP - MARGIN;
|
|
var flip = below < MIN_HEIGHT && above > below;
|
|
var room = Math.max(flip ? above : below, MIN_HEIGHT);
|
|
|
|
menu.style.position = 'fixed';
|
|
menu.style.left = r.left + 'px';
|
|
menu.style.minWidth = r.width + 'px';
|
|
menu.style.maxWidth = Math.max(r.width, Math.min(520, window.innerWidth - (MARGIN * 2))) + 'px';
|
|
menu.style.maxHeight = room + 'px';
|
|
menu.style.overflowY = 'auto';
|
|
menu.style.overflowX = 'hidden';
|
|
menu.style.zIndex = '2000';
|
|
|
|
if (flip) {
|
|
menu.style.top = '';
|
|
menu.style.bottom = (window.innerHeight - r.top + GAP) + 'px';
|
|
} else {
|
|
menu.style.bottom = '';
|
|
menu.style.top = (r.bottom + GAP) + 'px';
|
|
}
|
|
|
|
// Keep it on screen horizontally too
|
|
var width = menu.offsetWidth || r.width;
|
|
if (r.left + width > window.innerWidth - MARGIN) {
|
|
menu.style.left = Math.max(MARGIN, window.innerWidth - width - MARGIN) + 'px';
|
|
}
|
|
}
|
|
|
|
function highlight(next) {
|
|
var nodes = menu.querySelectorAll('.itflow-ac-item');
|
|
if (!nodes.length) {
|
|
return;
|
|
}
|
|
if (active >= 0 && nodes[active]) {
|
|
nodes[active].classList.remove('active');
|
|
}
|
|
active = next < 0 ? nodes.length - 1 : (next >= nodes.length ? 0 : next);
|
|
nodes[active].classList.add('active');
|
|
nodes[active].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
|
|
function choose(index) {
|
|
var item = items[index];
|
|
if (!item) {
|
|
return;
|
|
}
|
|
close();
|
|
if (typeof opts.onSelect === 'function') {
|
|
opts.onSelect(item);
|
|
}
|
|
}
|
|
|
|
function open(term) {
|
|
var source = typeof opts.source === 'function' ? opts.source(term) : (opts.source || []);
|
|
var matcher = typeof opts.match === 'function' ? opts.match : defaultMatch;
|
|
var all = source.filter(function (item) {
|
|
return matcher(item, term);
|
|
});
|
|
items = all.slice(0, maxItems);
|
|
if (!items.length) {
|
|
close();
|
|
return;
|
|
}
|
|
menu.innerHTML = '';
|
|
items.forEach(function (item, i) {
|
|
var el = document.createElement('button');
|
|
el.type = 'button';
|
|
el.className = 'itflow-ac-item dropdown-item text-wrap';
|
|
el.setAttribute('role', 'option');
|
|
el.innerHTML = typeof opts.render === 'function'
|
|
? opts.render(item)
|
|
: itflowEscapeHtml(item.label);
|
|
el.addEventListener('mousedown', function (e) {
|
|
// mousedown, not click - blur would close the menu first
|
|
e.preventDefault();
|
|
choose(i);
|
|
});
|
|
menu.appendChild(el);
|
|
});
|
|
if (all.length > items.length) {
|
|
var more = document.createElement('div');
|
|
more.className = 'itflow-ac-more small text-muted px-3 py-2 border-top';
|
|
more.textContent = 'Showing ' + items.length + ' of ' + all.length + ' - keep typing to narrow';
|
|
menu.appendChild(more);
|
|
}
|
|
menu.classList.add('show');
|
|
input.setAttribute('aria-expanded', 'true');
|
|
active = -1;
|
|
position();
|
|
menu.scrollTop = 0;
|
|
}
|
|
|
|
input.setAttribute('autocomplete', 'off');
|
|
input.setAttribute('aria-autocomplete', 'list');
|
|
|
|
input.addEventListener('input', function () {
|
|
var term = input.value.trim().toLowerCase();
|
|
if (term.length < minLength) {
|
|
close();
|
|
return;
|
|
}
|
|
open(term);
|
|
});
|
|
|
|
input.addEventListener('keydown', function (e) {
|
|
if (!menu.classList.contains('show')) {
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
highlight(active + 1);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
highlight(active - 1);
|
|
} else if (e.key === 'Enter') {
|
|
if (active >= 0) {
|
|
e.preventDefault();
|
|
choose(active);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
close();
|
|
}
|
|
});
|
|
|
|
input.addEventListener('blur', function () {
|
|
setTimeout(close, 150);
|
|
});
|
|
|
|
function reposition() {
|
|
if (menu.classList.contains('show')) {
|
|
position();
|
|
}
|
|
}
|
|
window.addEventListener('resize', reposition);
|
|
// position: fixed does not follow the page, and these menus open inside
|
|
// scrollable modal bodies - so track scroll on the way up the tree too
|
|
window.addEventListener('scroll', reposition, true);
|
|
|
|
document.addEventListener('click', function (e) {
|
|
if (e.target !== input && !menu.contains(e.target)) {
|
|
close();
|
|
}
|
|
});
|
|
|
|
input.itflowAutocomplete = { close: close, reposition: reposition };
|
|
}
|