mirror of
https://github.com/itflow-org/itflow
synced 2026-11-12 07:27:22 +00:00
345 lines
12 KiB
JavaScript
345 lines
12 KiB
JavaScript
/**
|
|
* Suggestions under a text field as you type - what a line on an invoice is
|
|
* called, an asset's operating system. The field stays free text: picking a
|
|
* suggestion is optional.
|
|
*
|
|
* 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)
|
|
* row: fn(item) -> what a suggestion shows (default: { label: item.label })
|
|
* label the main line
|
|
* description a second, quieter line - cut short with an ellipsis
|
|
* icon an icon in front, "fas fa-wrench"; iconTitle says what it means
|
|
* aside a value at the end of the row, a price say
|
|
* asideNote a quieter line under it; asideTone "danger" colours it
|
|
* onSelect: fn(item)
|
|
* })
|
|
*
|
|
* It looks and moves like the select picker (js/itflow_select.js), whose
|
|
* panel, list and option classes it is drawn with: the row under the mouse or
|
|
* the arrow keys is the one picked out, what was typed is marked in each row,
|
|
* and a long list says how many more there are. Up/Down to move, Enter to
|
|
* pick, Escape to dismiss. Nothing is picked out until you move, so Enter on
|
|
* what you typed still just sends the form.
|
|
*
|
|
* The list is capped to the room the window has and scrolls inside itself,
|
|
* and opens above the field 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 MAX_WIDTH = 520;
|
|
var items = [];
|
|
var rows = [];
|
|
var active = -1;
|
|
var isOpen = false;
|
|
|
|
itflowAutocomplete.count = (itflowAutocomplete.count || 0) + 1;
|
|
var id = 'itflow-ac-' + itflowAutocomplete.count;
|
|
|
|
function make(tag, className, text) {
|
|
var node = document.createElement(tag);
|
|
if (className) {
|
|
node.className = className;
|
|
}
|
|
if (text != null && text !== '') {
|
|
node.textContent = text;
|
|
}
|
|
return node;
|
|
}
|
|
|
|
var panel = make('div', 'itflow-select-panel itflow-ac-panel');
|
|
var list = make('div', 'itflow-select-list');
|
|
list.id = id + '-list';
|
|
list.setAttribute('role', 'listbox');
|
|
panel.appendChild(list);
|
|
|
|
function defaultMatch(item, term) {
|
|
return String(item.label || '').toLowerCase().indexOf(term) !== -1;
|
|
}
|
|
|
|
/* Write text into node with what was typed in <mark>, as the select picker does. */
|
|
function highlight(node, text, term) {
|
|
text = String(text == null ? '' : text);
|
|
var lower = text.toLowerCase();
|
|
var at = term ? lower.indexOf(term) : -1;
|
|
var pos = 0;
|
|
while (at !== -1) {
|
|
if (at > pos) {
|
|
node.appendChild(document.createTextNode(text.slice(pos, at)));
|
|
}
|
|
node.appendChild(make('mark', '', text.slice(at, at + term.length)));
|
|
pos = at + term.length;
|
|
at = lower.indexOf(term, pos);
|
|
}
|
|
if (pos < text.length) {
|
|
node.appendChild(document.createTextNode(text.slice(pos)));
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
isOpen = false;
|
|
panel.remove();
|
|
list.textContent = '';
|
|
list.style.maxHeight = '';
|
|
rows = [];
|
|
active = -1;
|
|
input.setAttribute('aria-expanded', 'false');
|
|
input.removeAttribute('aria-activedescendant');
|
|
}
|
|
|
|
/**
|
|
* Size and place the panel against whatever room the viewport actually has.
|
|
* Without this a long product list runs off the bottom of the page.
|
|
*/
|
|
function place() {
|
|
var r = input.getBoundingClientRect();
|
|
var below = window.innerHeight - r.bottom - GAP - MARGIN;
|
|
var above = r.top - GAP - MARGIN;
|
|
var up = below < MIN_HEIGHT && above > below;
|
|
var room = Math.max(up ? above : below, MIN_HEIGHT);
|
|
|
|
panel.style.left = r.left + 'px';
|
|
panel.style.minWidth = r.width + 'px';
|
|
panel.style.maxWidth = Math.max(r.width, Math.min(MAX_WIDTH, window.innerWidth - (MARGIN * 2))) + 'px';
|
|
|
|
// The list is as tall as the stylesheet lets it be, less whatever doesn't fit
|
|
list.style.maxHeight = '';
|
|
var height = panel.offsetHeight;
|
|
if (height > room) {
|
|
list.style.maxHeight = Math.max(96, list.offsetHeight - (height - room)) + 'px';
|
|
}
|
|
|
|
panel.classList.toggle('is-above', up);
|
|
if (up) {
|
|
panel.style.top = '';
|
|
panel.style.bottom = (window.innerHeight - r.top + GAP) + 'px';
|
|
} else {
|
|
panel.style.bottom = '';
|
|
panel.style.top = (r.bottom + GAP) + 'px';
|
|
}
|
|
|
|
// Keep it on screen horizontally too
|
|
var width = panel.offsetWidth || r.width;
|
|
if (r.left + width > window.innerWidth - MARGIN) {
|
|
panel.style.left = Math.max(MARGIN, window.innerWidth - width - MARGIN) + 'px';
|
|
}
|
|
}
|
|
|
|
function setActive(next, scroll) {
|
|
if (!rows.length) {
|
|
return;
|
|
}
|
|
if (active >= 0 && rows[active]) {
|
|
rows[active].classList.remove('is-active');
|
|
}
|
|
active = next < 0 ? rows.length - 1 : (next >= rows.length ? 0 : next);
|
|
rows[active].classList.add('is-active');
|
|
input.setAttribute('aria-activedescendant', rows[active].id);
|
|
if (scroll) {
|
|
rows[active].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
function choose(index) {
|
|
var item = items[index];
|
|
if (!item) {
|
|
return;
|
|
}
|
|
close();
|
|
if (typeof opts.onSelect === 'function') {
|
|
opts.onSelect(item);
|
|
}
|
|
}
|
|
|
|
function draw(item, index, term) {
|
|
var row = typeof opts.row === 'function' ? (opts.row(item) || {}) : { label: item.label };
|
|
var node = make('div', 'itflow-select-option');
|
|
node.id = id + '-o' + index;
|
|
node.setAttribute('role', 'option');
|
|
node.setAttribute('data-row', index);
|
|
node.setAttribute('aria-selected', 'false');
|
|
|
|
if (row.icon) {
|
|
var icon = make('span', 'itflow-select-icon');
|
|
icon.appendChild(make('i', row.icon));
|
|
if (row.iconTitle) {
|
|
icon.title = row.iconTitle;
|
|
} else {
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
}
|
|
node.appendChild(icon);
|
|
}
|
|
|
|
var text = make('span', 'itflow-select-text');
|
|
var label = make('span', 'itflow-select-label');
|
|
highlight(label, row.label, term);
|
|
text.appendChild(label);
|
|
if (row.description) {
|
|
var description = make('span', 'itflow-select-description');
|
|
highlight(description, row.description, term);
|
|
text.appendChild(description);
|
|
}
|
|
node.appendChild(text);
|
|
|
|
if (row.aside || row.asideNote) {
|
|
var aside = make('span', 'itflow-ac-aside');
|
|
if (row.aside) {
|
|
aside.appendChild(make('span', 'itflow-ac-aside-value', row.aside));
|
|
}
|
|
if (row.asideNote) {
|
|
aside.appendChild(make('span', 'itflow-ac-aside-note' + (row.asideTone ? ' text-' + row.asideTone : ''), row.asideNote));
|
|
}
|
|
node.appendChild(aside);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
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) {
|
|
// Nothing like it - the field takes what was typed as it is
|
|
close();
|
|
return;
|
|
}
|
|
|
|
list.textContent = '';
|
|
rows = items.map(function (item, i) {
|
|
return list.appendChild(draw(item, i, term));
|
|
});
|
|
if (all.length > items.length) {
|
|
list.appendChild(make('div', 'itflow-select-more', 'Showing ' + items.length + ' of ' + all.length.toLocaleString() + ' - type to narrow it down'));
|
|
}
|
|
active = -1;
|
|
input.removeAttribute('aria-activedescendant');
|
|
|
|
if (!isOpen) {
|
|
isOpen = true;
|
|
// Inside the window the field is in, as the select picker's panel is
|
|
(input.closest('.modal, .offcanvas') || document.body).appendChild(panel);
|
|
input.setAttribute('aria-expanded', 'true');
|
|
}
|
|
place();
|
|
list.scrollTop = 0;
|
|
}
|
|
|
|
input.setAttribute('autocomplete', 'off');
|
|
input.setAttribute('role', 'combobox');
|
|
input.setAttribute('aria-autocomplete', 'list');
|
|
input.setAttribute('aria-expanded', 'false');
|
|
input.setAttribute('aria-controls', list.id);
|
|
|
|
input.addEventListener('input', function () {
|
|
var term = input.value.trim().toLowerCase();
|
|
if (term.length < minLength) {
|
|
close();
|
|
return;
|
|
}
|
|
open(term);
|
|
});
|
|
|
|
input.addEventListener('keydown', function (e) {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setActive(active + 1, true);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActive(active - 1, true);
|
|
} else if (e.key === 'Enter') {
|
|
if (active >= 0) {
|
|
e.preventDefault();
|
|
choose(active);
|
|
}
|
|
} else if (e.key === 'Escape') {
|
|
// Only the list - not the window the field is in as well
|
|
e.stopPropagation();
|
|
close();
|
|
}
|
|
});
|
|
|
|
input.addEventListener('blur', function () {
|
|
setTimeout(close, 150);
|
|
});
|
|
|
|
// mousedown, not click - the field would lose focus and close the list first
|
|
panel.addEventListener('mousedown', function (e) {
|
|
e.preventDefault();
|
|
var node = e.target.closest('[data-row]');
|
|
if (node && e.button === 0) {
|
|
choose(parseInt(node.getAttribute('data-row'), 10));
|
|
}
|
|
});
|
|
// The row under the mouse is the one picked out, as in the select picker
|
|
list.addEventListener('mousemove', function (e) {
|
|
var node = e.target.closest('[data-row]');
|
|
var index = node ? parseInt(node.getAttribute('data-row'), 10) : -1;
|
|
if (index >= 0 && index !== active) {
|
|
setActive(index, false);
|
|
}
|
|
});
|
|
|
|
function reposition(e) {
|
|
// The list's own scrolling moves nothing
|
|
if (e && e.target && e.target.nodeType === 1 && panel.contains(e.target)) {
|
|
return;
|
|
}
|
|
if (!input.isConnected) {
|
|
// The window the field was in has gone
|
|
close();
|
|
window.removeEventListener('resize', reposition);
|
|
window.removeEventListener('scroll', reposition, true);
|
|
document.removeEventListener('click', outside);
|
|
document.removeEventListener('itflow:dismiss', dismiss);
|
|
return;
|
|
}
|
|
if (isOpen) {
|
|
place();
|
|
}
|
|
}
|
|
function outside(e) {
|
|
if (!input.isConnected) {
|
|
reposition();
|
|
} else if (e.target !== input && !panel.contains(e.target)) {
|
|
close();
|
|
}
|
|
}
|
|
function dismiss() {
|
|
close();
|
|
}
|
|
window.addEventListener('resize', reposition);
|
|
// position: fixed does not follow the page, and these lists open inside
|
|
// scrollable modal bodies - so track scroll on the way up the tree too
|
|
window.addEventListener('scroll', reposition, true);
|
|
document.addEventListener('click', outside);
|
|
// A click into a reply box, which this page never hears (itflowDismissPopups(), js/itflow_select.js)
|
|
document.addEventListener('itflow:dismiss', dismiss);
|
|
|
|
input.itflowAutocomplete = { close: close, reposition: reposition };
|
|
}
|