From 520f0836bfb0214b9abdb5ca38be3bd761958c43 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 9 Apr 2026 16:07:49 +0000 Subject: [PATCH] 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 --- api/app/static/css/style.css | 3 + api/app/static/js/mappings.js | 55 ++++-------- api/app/static/js/shared.js | 143 +++++++++++++++++++++++++++----- api/app/templates/base.html | 2 +- api/app/templates/mappings.html | 2 +- api/tests/e2e/test_mappings.py | 99 ++++++++++++++++++++++ 6 files changed, 242 insertions(+), 62 deletions(-) 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()