fix(autocomplete): add keyboard navigation and fix scroll/blur in all CODMAT dropdowns

Extract shared setupAutocomplete() into shared.js so all three autocomplete
instances (mappings modal, inline add, quick-map modal) get keyboard nav
(ArrowDown/Up/Enter/Escape), scroll-safe blur handling, and capture-phase
keydown to prevent browser interception. Remove old onmousedown inline
handlers, use data-codmat/data-label attributes instead.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-09 16:07:49 +00:00
parent 84e5d55592
commit 520f0836bf
6 changed files with 242 additions and 62 deletions

View File

@@ -700,6 +700,9 @@ tr.mapping-deleted td {
.autocomplete-item:hover, .autocomplete-item.active {
background-color: var(--surface-raised);
}
.autocomplete-dropdown.keyboard-active .autocomplete-item:hover:not(.active) {
background: inherit;
}
.autocomplete-item .codmat {
font-weight: 600;
color: var(--text-primary);

View File

@@ -279,8 +279,6 @@ function goPage(p) {
// ── Multi-CODMAT Add Modal (R11) ─────────────────
let acTimeouts = {};
function initAddModal() {
const modal = document.getElementById('addModal');
if (!modal) return;
@@ -373,7 +371,7 @@ function addCodmatLine() {
div.innerHTML = `
<div class="qm-row">
<div class="qm-codmat-wrap position-relative">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="CODMAT..." autocomplete="off" data-idx="${idx}">
<input type="text" class="form-control form-control-sm cl-codmat" placeholder="CODMAT..." autocomplete="nope" data-idx="${idx}">
<div class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
</div>
<input type="number" class="form-control form-control-sm cl-cantitate" value="1" step="0.001" min="0.001" title="Cantitate ROA" style="width:70px">
@@ -388,14 +386,7 @@ function addCodmatLine() {
const dropdown = div.querySelector('.cl-ac-dropdown');
const selected = div.querySelector('.cl-selected');
input.addEventListener('input', () => {
const key = 'cl_' + idx;
clearTimeout(acTimeouts[key]);
acTimeouts[key] = setTimeout(() => clAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
setupAutocomplete(input, dropdown, selected, clAutocomplete);
}
async function clAutocomplete(input, dropdown, selectedEl) {
@@ -407,22 +398,16 @@ async function clAutocomplete(input, dropdown, selectedEl) {
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="clSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
dropdown.innerHTML = data.results.map((r, i) => {
const label = r.denumire + (r.um ? ` (${r.um})` : '');
return `<div class="autocomplete-item" id="ac-cl-${i}" data-codmat="${esc(r.codmat)}" data-label="${esc(label)}">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
</div>`;
}).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function clSelectArticle(el, codmat, label) {
const line = el.closest('.codmat-line');
line.querySelector('.cl-codmat').value = codmat;
line.querySelector('.cl-selected').textContent = label;
line.querySelector('.cl-ac-dropdown').classList.add('d-none');
}
async function saveMapping() {
const sku = document.getElementById('inputSku').value.trim();
if (!sku) { alert('SKU este obligatoriu'); return; }
@@ -528,7 +513,7 @@ function showInlineAddRow() {
row.innerHTML = `
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
<div class="position-relative" style="flex:1;min-width:0">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="off">
<input type="text" class="form-control form-control-sm" id="inlineCodmat" placeholder="Cauta CODMAT..." autocomplete="nope">
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
<small class="text-muted" id="inlineSelected"></small>
</div>
@@ -543,15 +528,8 @@ function showInlineAddRow() {
const input = document.getElementById('inlineCodmat');
const dropdown = document.getElementById('inlineAcDropdown');
const selected = document.getElementById('inlineSelected');
let inlineAcTimeout = null;
input.addEventListener('input', () => {
clearTimeout(inlineAcTimeout);
inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
setupAutocomplete(input, dropdown, selected, inlineAutocomplete);
}
async function inlineAutocomplete(input, dropdown, selectedEl) {
@@ -561,21 +539,16 @@ async function inlineAutocomplete(input, dropdown, selectedEl) {
const res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="inlineSelectArticle('${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
dropdown.innerHTML = data.results.map((r, i) => {
const label = r.denumire + (r.um ? ` (${r.um})` : '');
return `<div class="autocomplete-item" id="ac-il-${i}" data-codmat="${esc(r.codmat)}" data-label="${esc(label)}">
<span class="codmat">${esc(r.codmat)}</span> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
</div>`;
}).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function inlineSelectArticle(codmat, label) {
document.getElementById('inlineCodmat').value = codmat;
document.getElementById('inlineSelected').textContent = label;
document.getElementById('inlineAcDropdown').classList.add('d-none');
}
async function saveInlineMapping() {
const sku = document.getElementById('inlineSku').value.trim();
const codmat = document.getElementById('inlineCodmat').value.trim();

View File

@@ -204,9 +204,126 @@ function renderMobileSegmented(containerId, pills, onSelect) {
});
}
// ── Shared Autocomplete ─────────────────────────
function setupAutocomplete(input, dropdown, selectedEl, fetchFn) {
let activeIndex = -1;
let acTimeout = null;
// Force-disable browser native autocomplete
input.setAttribute('autocomplete', 'off');
input.setAttribute('autocorrect', 'off');
input.setAttribute('autocapitalize', 'off');
input.setAttribute('spellcheck', 'false');
if (!input.name) input.name = 'ac_' + Math.random().toString(36).slice(2, 8);
// Debounced input → fetch
input.addEventListener('input', () => {
clearTimeout(acTimeout);
acTimeout = setTimeout(() => {
activeIndex = -1;
fetchFn(input, dropdown, selectedEl);
}, 250);
});
// Prevent blur when interacting with dropdown (scroll, click)
dropdown.addEventListener('mousedown', (e) => {
e.preventDefault();
});
// Click selection on items (delegated)
dropdown.addEventListener('click', (e) => {
const item = e.target.closest('.autocomplete-item');
if (!item) return;
selectItem(item);
});
// Switch back to mouse mode on mousemove
dropdown.addEventListener('mousemove', () => {
dropdown.classList.remove('keyboard-active');
});
// Blur → close dropdown
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 150);
});
// Keyboard navigation — capture phase to beat browser/extensions
input.addEventListener('keydown', (e) => {
if (dropdown.classList.contains('d-none')) return;
const items = dropdown.querySelectorAll('.autocomplete-item');
if (!items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
dropdown.classList.add('keyboard-active');
if (activeIndex < items.length - 1) activeIndex++;
updateActive(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
dropdown.classList.add('keyboard-active');
if (activeIndex > 0) activeIndex--;
updateActive(items);
} else if (e.key === 'Enter') {
if (activeIndex >= 0 && activeIndex < items.length) {
e.preventDefault();
e.stopPropagation();
selectItem(items[activeIndex]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
dropdown.classList.add('d-none');
activeIndex = -1;
}
}, true); // capture phase
// Track dropdown open/close
input.setAttribute('aria-expanded', 'false');
function updateActive(items) {
items.forEach((it, i) => {
const isActive = i === activeIndex;
it.classList.toggle('active', isActive);
if (isActive) {
it.scrollIntoView({ block: 'nearest' });
}
});
}
function selectItem(item) {
const codmat = item.dataset.codmat;
const label = item.dataset.label;
if (!codmat) return;
// Find parent context: .codmat-line (mappings modal), .qm-line (quick-map), or inline
const line = input.closest('.codmat-line') || input.closest('.qm-line');
if (line) {
const codmatInput = line.querySelector('.cl-codmat') || line.querySelector('.qm-codmat');
const selectedLabel = line.querySelector('.cl-selected') || line.querySelector('.qm-selected');
if (codmatInput) codmatInput.value = codmat;
if (selectedLabel) selectedLabel.textContent = label;
} else {
// Inline context
input.value = codmat;
selectedEl.textContent = label;
}
dropdown.classList.add('d-none');
input.setAttribute('aria-expanded', 'false');
activeIndex = -1;
}
// Observe dropdown visibility for aria-expanded
const observer = new MutationObserver(() => {
const open = !dropdown.classList.contains('d-none');
input.setAttribute('aria-expanded', String(open));
});
observer.observe(dropdown, { attributes: true, attributeFilter: ['class'] });
}
// ── Shared Quick Map Modal ────────────────────────
let _qmOnSave = null;
let _qmAcTimeout = null;
/**
* Open the shared quick-map modal.
@@ -276,13 +393,7 @@ function addQmCodmatLine(prefill) {
const dropdown = div.querySelector('.qm-ac-dropdown');
const selected = div.querySelector('.qm-selected');
input.addEventListener('input', () => {
clearTimeout(_qmAcTimeout);
_qmAcTimeout = setTimeout(() => _qmAutocomplete(input, dropdown, selected), 250);
});
input.addEventListener('blur', () => {
setTimeout(() => dropdown.classList.add('d-none'), 200);
});
setupAutocomplete(input, dropdown, selected, _qmAutocomplete);
}
async function _qmAutocomplete(input, dropdown, selectedEl) {
@@ -294,22 +405,16 @@ async function _qmAutocomplete(input, dropdown, selectedEl) {
const data = await res.json();
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
dropdown.innerHTML = data.results.map(r =>
`<div class="autocomplete-item" onmousedown="_qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
dropdown.innerHTML = data.results.map((r, i) => {
const label = r.denumire + (r.um ? ` (${r.um})` : '');
return `<div class="autocomplete-item" id="ac-qm-${i}" data-codmat="${esc(r.codmat)}" data-label="${esc(label)}">
<span class="codmat">${esc(r.codmat)}</span> &mdash; <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
</div>`
).join('');
</div>`;
}).join('');
dropdown.classList.remove('d-none');
} catch { dropdown.classList.add('d-none'); }
}
function _qmSelectArticle(el, codmat, label) {
const line = el.closest('.qm-line');
line.querySelector('.qm-codmat').value = codmat;
line.querySelector('.qm-selected').textContent = label;
line.querySelector('.qm-ac-dropdown').classList.add('d-none');
}
async function saveQuickMapping() {
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
const mappings = [];