Files
itflow/js/autocomplete.js
johnnyq 755e488d6c Add and change lines on invoices, quotes and recurring invoices on the page itself, without a reload: the row of boxes under the lines is now one field - type a product and Enter takes the one picked out, adding it as a line with its description, price and tax, billed as the product usually is on a quote and counted per user or per device on a recurring invoice, or type anything and pick the Add row for a line of its own - and the new line opens with its quantity ready to type over, Enter moving on to a price it still needs and then back to the field; a line name, description, quantity, price and tax are changed where they sit, by a click or by Tab and Enter, saved on Enter or on leaving, put back by Escape, with the tax picked from the select picker and a counted quantity left to the Edit Line window; a quote line menu gains Billed, one time to yearly; agent/ajax.php gains billing_item_add and billing_item_set with the checks post.php billing_item_save makes, answering JSON, and functions/billing.php gains billingItemSetField, which replaces one value and hands the line to billingItemSave so stock, tax and totals follow as before, saves nothing when the value is the same, and gives the audit log what changed; js/billing_items.js is rewritten to send saves one at a time in order and then fetch the page again, taking from it the lines by id and every element marked data-billing-live - the totals and amount due in includes/billing_paper.php, the header card, tax warning and side column on the three pages - so nothing is drawn twice, a field in use is left alone, and a document no longer open for changes reloads; the paper carries the products as JSON and the tax options, and itflowBillingItems no longer takes products; js/autocomplete.js gains pickFirst and an extra row kept in view under the list, and opens above the field when the list does not fit below; getProductsForAutocomplete encodes with JSON_HEX_TAG and JSON_HEX_AMP; the guest and client portal pages are unchanged; add a 26.11.8 changelog section
2026-11-12 16:14:30 -05:00

373 lines
14 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)
* extra: fn(typed) -> one more row under the suggestions, drawn as row
* gives them, for taking what was typed as it is - or null.
* It stays in view under a long list, and is there when
* nothing matches too. onExtra: fn(typed)
* pickFirst: true picks out the first row as the list opens, so Enter
* takes it - for a field that is for picking
* })
*
* 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 - unless pickFirst says otherwise.
*
* 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 extra = null;
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);
// The row for what was typed sits under the list, not in it, so it doesn't scroll away
var foot = make('div', 'itflow-ac-foot');
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 = '';
foot.textContent = '';
list.style.maxHeight = '';
rows = [];
extra = null;
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;
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 -
// above the field when it doesn't all fit below and there is more room there, as the select picker goes
list.style.maxHeight = '';
var height = panel.offsetHeight;
var up = height > below && above > below;
var room = Math.max(up ? above : below, MIN_HEIGHT);
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];
// The row after the suggestions is the one for what was typed
if (!item && !(extra && index === items.length)) {
return;
}
var typed = input.value.trim();
close();
if (item && typeof opts.onSelect === 'function') {
opts.onSelect(item);
} else if (!item && typeof opts.onExtra === 'function') {
opts.onExtra(typed);
}
}
function draw(item, index, term, as) {
var row = as || (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);
extra = typeof opts.extra === 'function' ? (opts.extra(input.value.trim()) || null) : null;
if (!items.length && !extra) {
// 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'));
}
foot.textContent = '';
if (extra) {
rows.push(foot.appendChild(draw(null, items.length, '', extra)));
panel.appendChild(foot);
} else {
foot.remove();
}
list.hidden = !items.length;
active = -1;
input.removeAttribute('aria-activedescendant');
if (opts.pickFirst) {
setActive(0, false);
}
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
panel.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 };
}