diff --git a/api/app/static/css/style.css b/api/app/static/css/style.css
index 5ec4b56..132c576 100644
--- a/api/app/static/css/style.css
+++ b/api/app/static/css/style.css
@@ -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);
diff --git a/api/app/static/js/mappings.js b/api/app/static/js/mappings.js
index 366d3a3..bcc511a 100644
--- a/api/app/static/js/mappings.js
+++ b/api/app/static/js/mappings.js
@@ -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 = `
@@ -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 =>
- `
+ dropdown.innerHTML = data.results.map((r, i) => {
+ const label = r.denumire + (r.um ? ` (${r.um})` : '');
+ return `
${esc(r.codmat)} — ${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
-
`
- ).join('');
+
`;
+ }).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 = `
@@ -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 =>
- `
+ dropdown.innerHTML = data.results.map((r, i) => {
+ const label = r.denumire + (r.um ? ` (${r.um})` : '');
+ return `
${esc(r.codmat)} — ${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
-
`
- ).join('');
+
`;
+ }).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();
diff --git a/api/app/static/js/shared.js b/api/app/static/js/shared.js
index 8edbcb5..04e7a92 100644
--- a/api/app/static/js/shared.js
+++ b/api/app/static/js/shared.js
@@ -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 =>
- `
+ dropdown.innerHTML = data.results.map((r, i) => {
+ const label = r.denumire + (r.um ? ` (${r.um})` : '');
+ return `
${esc(r.codmat)} — ${esc(r.denumire)}${r.um ? ` (${esc(r.um)})` : ''}
-
`
- ).join('');
+
`;
+ }).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 = [];
diff --git a/api/app/templates/base.html b/api/app/templates/base.html
index ca8fa95..3471411 100644
--- a/api/app/templates/base.html
+++ b/api/app/templates/base.html
@@ -169,7 +169,7 @@
-
+
+
{% endblock %}
diff --git a/api/tests/e2e/test_mappings.py b/api/tests/e2e/test_mappings.py
index df0531c..73fd465 100644
--- a/api/tests/e2e/test_mappings.py
+++ b/api/tests/e2e/test_mappings.py
@@ -1,4 +1,6 @@
"""E2E: Mappings page with flat-row list, sorting, multi-CODMAT modal."""
+import re
+
import pytest
from playwright.sync_api import Page, expect
@@ -77,3 +79,100 @@ def test_inline_add_button_exists(page: Page):
"""Verify 'Adauga Mapare' button is present."""
btn = page.locator("button", has_text="Adauga Mapare")
expect(btn).to_be_visible()
+
+
+# ── Autocomplete keyboard & scroll tests ─────────
+
+MOCK_ARTICLES = [
+ {"codmat": f"ART{i:03}", "denumire": f"Articol Test {i}", "um": "BUC"}
+ for i in range(1, 20)
+]
+
+
+@pytest.fixture
+def mock_articles(page: Page):
+ """Mock /api/articles/search to return test data without Oracle."""
+ def handle(route):
+ route.fulfill(json={"results": MOCK_ARTICLES})
+ page.route("**/api/articles/search*", handle)
+ yield
+ page.unroute("**/api/articles/search*")
+
+
+def _open_modal_and_type(page: Page, query: str = "ART"):
+ """Open add-modal, type in CODMAT input, wait for dropdown."""
+ page.locator("button[data-bs-target='#addModal']").first.click()
+ page.wait_for_timeout(400)
+ codmat_input = page.locator("#codmatLines .cl-codmat").first
+ codmat_input.fill(query)
+ # Wait for debounce + render
+ page.wait_for_timeout(400)
+ return codmat_input
+
+
+def test_autocomplete_keyboard_navigation(page: Page, mock_articles):
+ """ArrowDown/Up moves .active class, Enter selects."""
+ codmat_input = _open_modal_and_type(page)
+
+ dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
+ expect(dropdown).to_be_visible()
+
+ # ArrowDown → first item active
+ codmat_input.press("ArrowDown")
+ first_item = dropdown.locator(".autocomplete-item").first
+ expect(first_item).to_have_class(re.compile("active"))
+
+ # ArrowDown again → second item active
+ codmat_input.press("ArrowDown")
+ second_item = dropdown.locator(".autocomplete-item").nth(1)
+ expect(second_item).to_have_class(re.compile("active"))
+ expect(first_item).not_to_have_class(re.compile("active"))
+
+ # ArrowUp → back to first
+ codmat_input.press("ArrowUp")
+ expect(first_item).to_have_class(re.compile("active"))
+
+ # Enter → selects the item
+ codmat_input.press("Enter")
+ expect(dropdown).to_be_hidden()
+ assert codmat_input.input_value() == "ART001"
+
+
+def test_autocomplete_escape_closes(page: Page, mock_articles):
+ """Escape closes dropdown."""
+ codmat_input = _open_modal_and_type(page)
+
+ dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
+ expect(dropdown).to_be_visible()
+
+ codmat_input.press("Escape")
+ expect(dropdown).to_be_hidden()
+
+
+def test_autocomplete_scroll_keeps_open(page: Page, mock_articles):
+ """Mouse wheel on dropdown doesn't close it (blur fix)."""
+ codmat_input = _open_modal_and_type(page)
+
+ dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
+ expect(dropdown).to_be_visible()
+
+ # Scroll inside the dropdown via mouse wheel
+ dropdown.evaluate("el => el.scrollTop = 100")
+ page.wait_for_timeout(300)
+
+ # Dropdown should still be visible
+ expect(dropdown).to_be_visible()
+
+
+def test_autocomplete_click_outside_closes(page: Page, mock_articles):
+ """Click outside closes dropdown (Tab away moves focus)."""
+ codmat_input = _open_modal_and_type(page)
+
+ dropdown = page.locator("#codmatLines .cl-ac-dropdown").first
+ expect(dropdown).to_be_visible()
+
+ # Tab away from the input to trigger blur
+ codmat_input.press("Tab")
+ page.wait_for_timeout(300)
+
+ expect(dropdown).to_be_hidden()