153 lines
4.9 KiB
JavaScript
Executable file
153 lines
4.9 KiB
JavaScript
Executable file
// searchAutocomplete.js — universal search dropdown for the scan/search input.
|
|
import { searchLocal } from './assetBrowser.js';
|
|
|
|
let _onLocalSelect = null;
|
|
let _onRemoteSearch = null;
|
|
let _input = null;
|
|
let _dropdown = null;
|
|
let _items = []; // { type:'asset'|'syncro', asset?, serial?, contact?, customerName?, query? }
|
|
let _activeIdx = -1;
|
|
let _inputTimer = null;
|
|
let _blurTimer = null;
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
export function initSearchAutocomplete({ onLocalSelect, onRemoteSearch }) {
|
|
_onLocalSelect = onLocalSelect;
|
|
_onRemoteSearch = onRemoteSearch;
|
|
|
|
_input = document.getElementById('scan-input');
|
|
if (!_input) return;
|
|
|
|
// Append dropdown inside scan-input-wrap so it's positioned relative to it
|
|
_dropdown = document.createElement('div');
|
|
_dropdown.id = 'search-autocomplete';
|
|
_dropdown.className = 'search-autocomplete';
|
|
_dropdown.hidden = true;
|
|
_input.closest('.scan-input-wrap').appendChild(_dropdown);
|
|
|
|
_input.addEventListener('input', _onInput);
|
|
|
|
_input.addEventListener('blur', () => {
|
|
_blurTimer = setTimeout(_close, 150);
|
|
});
|
|
|
|
_input.addEventListener('focus', () => {
|
|
clearTimeout(_blurTimer);
|
|
if (_input.value.trim().length >= 2) _onInput();
|
|
});
|
|
|
|
// Prevent blur when clicking a dropdown item
|
|
_dropdown.addEventListener('mousedown', e => e.preventDefault());
|
|
|
|
_dropdown.addEventListener('click', e => {
|
|
const item = e.target.closest('.ac-item');
|
|
if (item) _selectIdx(Number(item.dataset.idx));
|
|
});
|
|
}
|
|
|
|
// ── Key handler — called by scanner's key interceptor ─────────────────────────
|
|
|
|
export function handleAutocompleteKey(e) {
|
|
if (_dropdown.hidden) return false;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
_setActive(Math.min(_activeIdx + 1, _items.length - 1));
|
|
return true;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
_setActive(Math.max(_activeIdx - 1, 0));
|
|
return true;
|
|
}
|
|
if (e.key === 'Enter') {
|
|
if (_activeIdx >= 0) {
|
|
e.preventDefault();
|
|
_selectIdx(_activeIdx);
|
|
return true;
|
|
}
|
|
_close(); // close but let scanner handle Enter
|
|
return false;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
_close();
|
|
return true; // prevent scanner's clearInput
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ── Internal ──────────────────────────────────────────────────────────────────
|
|
|
|
function _onInput() {
|
|
clearTimeout(_inputTimer);
|
|
_inputTimer = setTimeout(() => {
|
|
const query = _input.value.trim();
|
|
if (query.length < 2) { _close(); return; }
|
|
_renderDropdown(query);
|
|
}, 200);
|
|
}
|
|
|
|
function _renderDropdown(query) {
|
|
const localResults = searchLocal(query);
|
|
_items = [
|
|
...localResults.map(r => ({ type: 'asset', ...r })),
|
|
{ type: 'syncro', query },
|
|
];
|
|
|
|
_activeIdx = -1;
|
|
_dropdown.innerHTML = _items.map((item, i) => {
|
|
if (item.type === 'syncro') {
|
|
return `<div class="ac-item ac-item-syncro" data-idx="${i}">
|
|
<svg class="ac-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
</svg>
|
|
<span>Search Syncro for <em>${_esc(item.query)}</em></span>
|
|
</div>`;
|
|
}
|
|
const { asset, serial, contact, customerName } = item;
|
|
const meta = [customerName, serial, contact].filter(Boolean).join(' · ');
|
|
return `<div class="ac-item" data-idx="${i}">
|
|
<svg class="ac-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
|
|
</svg>
|
|
<div class="ac-item-body">
|
|
<div class="ac-item-name">${_esc(asset.name ?? `Asset ${asset.id}`)}</div>
|
|
${meta ? `<div class="ac-item-meta">${_esc(meta)}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
_dropdown.hidden = false;
|
|
}
|
|
|
|
function _setActive(idx) {
|
|
_activeIdx = idx;
|
|
_dropdown.querySelectorAll('.ac-item').forEach((el, i) => {
|
|
el.classList.toggle('ac-active', i === idx);
|
|
if (i === idx) el.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
function _selectIdx(idx) {
|
|
const item = _items[idx];
|
|
if (!item) return;
|
|
_close();
|
|
_input.value = '';
|
|
_input.dispatchEvent(new Event('input')); // sync clear-btn visibility
|
|
if (item.type === 'asset') {
|
|
_onLocalSelect?.(item.asset);
|
|
} else {
|
|
_onRemoteSearch?.(item.query);
|
|
}
|
|
}
|
|
|
|
function _close() {
|
|
_dropdown.hidden = true;
|
|
_activeIdx = -1;
|
|
_items = [];
|
|
}
|
|
|
|
function _esc(s) {
|
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|