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:
@@ -700,6 +700,9 @@ tr.mapping-deleted td {
|
|||||||
.autocomplete-item:hover, .autocomplete-item.active {
|
.autocomplete-item:hover, .autocomplete-item.active {
|
||||||
background-color: var(--surface-raised);
|
background-color: var(--surface-raised);
|
||||||
}
|
}
|
||||||
|
.autocomplete-dropdown.keyboard-active .autocomplete-item:hover:not(.active) {
|
||||||
|
background: inherit;
|
||||||
|
}
|
||||||
.autocomplete-item .codmat {
|
.autocomplete-item .codmat {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
|||||||
@@ -279,8 +279,6 @@ function goPage(p) {
|
|||||||
|
|
||||||
// ── Multi-CODMAT Add Modal (R11) ─────────────────
|
// ── Multi-CODMAT Add Modal (R11) ─────────────────
|
||||||
|
|
||||||
let acTimeouts = {};
|
|
||||||
|
|
||||||
function initAddModal() {
|
function initAddModal() {
|
||||||
const modal = document.getElementById('addModal');
|
const modal = document.getElementById('addModal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -373,7 +371,7 @@ function addCodmatLine() {
|
|||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="qm-row">
|
<div class="qm-row">
|
||||||
<div class="qm-codmat-wrap position-relative">
|
<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 class="autocomplete-dropdown d-none cl-ac-dropdown"></div>
|
||||||
</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">
|
<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 dropdown = div.querySelector('.cl-ac-dropdown');
|
||||||
const selected = div.querySelector('.cl-selected');
|
const selected = div.querySelector('.cl-selected');
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
setupAutocomplete(input, dropdown, selected, clAutocomplete);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clAutocomplete(input, dropdown, selectedEl) {
|
async function clAutocomplete(input, dropdown, selectedEl) {
|
||||||
@@ -407,22 +398,16 @@ async function clAutocomplete(input, dropdown, selectedEl) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
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) => {
|
||||||
`<div class="autocomplete-item" onmousedown="clSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
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>` : ''}
|
<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>`
|
</div>`;
|
||||||
).join('');
|
}).join('');
|
||||||
dropdown.classList.remove('d-none');
|
dropdown.classList.remove('d-none');
|
||||||
} catch { dropdown.classList.add('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() {
|
async function saveMapping() {
|
||||||
const sku = document.getElementById('inputSku').value.trim();
|
const sku = document.getElementById('inputSku').value.trim();
|
||||||
if (!sku) { alert('SKU este obligatoriu'); return; }
|
if (!sku) { alert('SKU este obligatoriu'); return; }
|
||||||
@@ -528,7 +513,7 @@ function showInlineAddRow() {
|
|||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input type="text" class="form-control form-control-sm" id="inlineSku" placeholder="SKU" style="width:140px">
|
<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">
|
<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>
|
<div class="autocomplete-dropdown d-none" id="inlineAcDropdown"></div>
|
||||||
<small class="text-muted" id="inlineSelected"></small>
|
<small class="text-muted" id="inlineSelected"></small>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,15 +528,8 @@ function showInlineAddRow() {
|
|||||||
const input = document.getElementById('inlineCodmat');
|
const input = document.getElementById('inlineCodmat');
|
||||||
const dropdown = document.getElementById('inlineAcDropdown');
|
const dropdown = document.getElementById('inlineAcDropdown');
|
||||||
const selected = document.getElementById('inlineSelected');
|
const selected = document.getElementById('inlineSelected');
|
||||||
let inlineAcTimeout = null;
|
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
setupAutocomplete(input, dropdown, selected, inlineAutocomplete);
|
||||||
clearTimeout(inlineAcTimeout);
|
|
||||||
inlineAcTimeout = setTimeout(() => inlineAutocomplete(input, dropdown, selected), 250);
|
|
||||||
});
|
|
||||||
input.addEventListener('blur', () => {
|
|
||||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inlineAutocomplete(input, dropdown, selectedEl) {
|
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 res = await fetch(`/api/articles/search?q=${encodeURIComponent(q)}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
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) => {
|
||||||
`<div class="autocomplete-item" onmousedown="inlineSelectArticle('${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
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>` : ''}
|
<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>`
|
</div>`;
|
||||||
).join('');
|
}).join('');
|
||||||
dropdown.classList.remove('d-none');
|
dropdown.classList.remove('d-none');
|
||||||
} catch { dropdown.classList.add('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() {
|
async function saveInlineMapping() {
|
||||||
const sku = document.getElementById('inlineSku').value.trim();
|
const sku = document.getElementById('inlineSku').value.trim();
|
||||||
const codmat = document.getElementById('inlineCodmat').value.trim();
|
const codmat = document.getElementById('inlineCodmat').value.trim();
|
||||||
|
|||||||
@@ -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 ────────────────────────
|
// ── Shared Quick Map Modal ────────────────────────
|
||||||
let _qmOnSave = null;
|
let _qmOnSave = null;
|
||||||
let _qmAcTimeout = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the shared quick-map modal.
|
* Open the shared quick-map modal.
|
||||||
@@ -276,13 +393,7 @@ function addQmCodmatLine(prefill) {
|
|||||||
const dropdown = div.querySelector('.qm-ac-dropdown');
|
const dropdown = div.querySelector('.qm-ac-dropdown');
|
||||||
const selected = div.querySelector('.qm-selected');
|
const selected = div.querySelector('.qm-selected');
|
||||||
|
|
||||||
input.addEventListener('input', () => {
|
setupAutocomplete(input, dropdown, selected, _qmAutocomplete);
|
||||||
clearTimeout(_qmAcTimeout);
|
|
||||||
_qmAcTimeout = setTimeout(() => _qmAutocomplete(input, dropdown, selected), 250);
|
|
||||||
});
|
|
||||||
input.addEventListener('blur', () => {
|
|
||||||
setTimeout(() => dropdown.classList.add('d-none'), 200);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _qmAutocomplete(input, dropdown, selectedEl) {
|
async function _qmAutocomplete(input, dropdown, selectedEl) {
|
||||||
@@ -294,22 +405,16 @@ async function _qmAutocomplete(input, dropdown, selectedEl) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.results || data.results.length === 0) { dropdown.classList.add('d-none'); return; }
|
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) => {
|
||||||
`<div class="autocomplete-item" onmousedown="_qmSelectArticle(this, '${esc(r.codmat)}', '${esc(r.denumire)}${r.um ? ' (' + esc(r.um) + ')' : ''}')">
|
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> — <span class="denumire">${esc(r.denumire)}</span>${r.um ? ` <small class="text-muted">(${esc(r.um)})</small>` : ''}
|
<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>`
|
</div>`;
|
||||||
).join('');
|
}).join('');
|
||||||
dropdown.classList.remove('d-none');
|
dropdown.classList.remove('d-none');
|
||||||
} catch { dropdown.classList.add('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() {
|
async function saveQuickMapping() {
|
||||||
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
|
const lines = document.querySelectorAll('#qmCodmatLines .qm-line');
|
||||||
const mappings = [];
|
const mappings = [];
|
||||||
|
|||||||
@@ -169,7 +169,7 @@
|
|||||||
|
|
||||||
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
<script>window.ROOT_PATH = "{{ rp }}";</script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="{{ rp }}/static/js/shared.js?v=45"></script>
|
<script src="{{ rp }}/static/js/shared.js?v=46"></script>
|
||||||
<script>
|
<script>
|
||||||
// Dark mode toggle
|
// Dark mode toggle
|
||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
|
|||||||
@@ -159,5 +159,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=14"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/mappings.js?v=17"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""E2E: Mappings page with flat-row list, sorting, multi-CODMAT modal."""
|
"""E2E: Mappings page with flat-row list, sorting, multi-CODMAT modal."""
|
||||||
|
import re
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from playwright.sync_api import Page, expect
|
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."""
|
"""Verify 'Adauga Mapare' button is present."""
|
||||||
btn = page.locator("button", has_text="Adauga Mapare")
|
btn = page.locator("button", has_text="Adauga Mapare")
|
||||||
expect(btn).to_be_visible()
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user