From 01b81624a399c6f41775a1e51216cfe04877e6b9 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 16:44:07 +0800 Subject: [PATCH 01/15] fix(change_request): render wizard search results as keyboard-reachable options (#580) The Create Change Request wizard renders its registrant search results server-side as plain table rows with a mouse click handler, and a pager made of tags without href. Neither can take keyboard focus, so a keyboard or screen-reader user could type a search but never pick a result (WCAG 2.1.1, 4.1.2). Rows are now options of a listbox (role, roving tabindex, aria-label with name and type), the pager is made of real buttons whose out-of-range side carries a disabled attribute instead of muted styling, the decorative type icons are hidden from assistive technology, and the range summary / empty-result message carry a status class the widget mirrors into a live region. Nothing changes visually. Tests pin the rendered semantics and the two bridge behaviours the widget relies on (selected-partner onchange, page re-render and clamp), none of which were covered before. --- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_create_wizard_search.py | 133 ++++++++++++++++++ .../wizards/create_wizard.py | 53 +++++-- 3 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 spp_change_request_v2/tests/test_create_wizard_search.py diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 0a0e4a967..392178942 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -38,3 +38,4 @@ from . import test_field_mapping_transform from . import test_routing_field from . import test_birthdate_mixin +from . import test_create_wizard_search diff --git a/spp_change_request_v2/tests/test_create_wizard_search.py b/spp_change_request_v2/tests/test_create_wizard_search.py new file mode 100644 index 000000000..e84cedfda --- /dev/null +++ b/spp_change_request_v2/tests/test_create_wizard_search.py @@ -0,0 +1,133 @@ +"""Tests for the registrant search in the Change Request creation wizard. + +The wizard renders its search results server-side as HTML and the browser +widget only wires up selection and paging. These tests pin down the markup +that keyboard users and assistive technology depend on (focusable rows with +a role and a name, a real button pager, status text for a live region) and +the bridge fields the widget writes. +""" + +from lxml import html as lxml_html + +from odoo.tests import tagged + +from .test_change_request import TestChangeRequestBase + +PREFIX = "A11Y Searchable" + + +@tagged("post_install", "-at_install", "cr_ux") +class TestCreateWizardSearch(TestChangeRequestBase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.individuals = cls.partner_model.create( + [{"name": f"{PREFIX} {index:02d}", "is_registrant": True, "is_group": False} for index in range(1, 13)] + ) + cls.search_group = cls.partner_model.create( + {"name": f"{PREFIX} Group", "is_registrant": True, "is_group": True} + ) + cls.wizard_model = cls.env["spp.cr.create.wizard"] + + def _wizard(self): + return self.wizard_model.create({"request_type_id": self.cr_type_edit_individual.id}) + + def _render(self, wizard, text, page=0): + wizard.search_text = text + wizard._onchange_search_text() + if page: + wizard._search_page = page + wizard._onchange_search_page() + return lxml_html.fragment_fromstring(str(wizard.search_results_html), create_parent="div") + + @staticmethod + def _rows(doc): + return doc.xpath('.//tr[contains(@class, "o_cr_search_result")]') + + @staticmethod + def _status_text(doc): + nodes = doc.xpath('.//*[contains(@class, "o_cr_search_status")]') + return nodes[0].text_content().strip() if nodes else None + + def test_rows_are_focusable_options(self): + """Rows are options of a listbox, one of them in the Tab order, each with an accessible name.""" + doc = self._render(self._wizard(), PREFIX) + + listbox = doc.xpath('.//tbody[@role="listbox"]') + self.assertEqual(len(listbox), 1) + self.assertTrue(listbox[0].get("aria-label")) + + rows = self._rows(doc) + self.assertEqual(len(rows), 10) + self.assertEqual([row.get("role") for row in rows], ["option"] * 10) + self.assertEqual([row.get("tabindex") for row in rows], ["0"] + ["-1"] * 9) + for row in rows: + self.assertEqual(row.get("aria-label"), f"{row.get('data-partner-name')}, Individual") + + def test_type_icon_is_hidden_from_assistive_tech(self): + """Decorative type icons are not announced.""" + doc = self._render(self._wizard(), PREFIX) + icons = doc.xpath('.//i[contains(@class, "fa")]') + self.assertEqual(len(icons), 10) + for icon in icons: + self.assertEqual(icon.get("aria-hidden"), "true") + + def test_pager_renders_buttons_with_disabled_edges(self): + """Previous/Next are real buttons; the edge of the range is disabled rather than merely styled.""" + wizard = self._wizard() + + doc = self._render(wizard, PREFIX) + prev = doc.xpath('.//button[contains(@class, "o_cr_page_prev")]')[0] + nxt = doc.xpath('.//button[contains(@class, "o_cr_page_next")]')[0] + self.assertEqual((prev.get("type"), nxt.get("type")), ("button", "button")) + self.assertIsNotNone(prev.get("disabled")) + self.assertIsNone(nxt.get("disabled")) + self.assertEqual((prev.get("data-page"), nxt.get("data-page")), ("-1", "1")) + + doc = self._render(wizard, PREFIX, page=1) + prev = doc.xpath('.//button[contains(@class, "o_cr_page_prev")]')[0] + nxt = doc.xpath('.//button[contains(@class, "o_cr_page_next")]')[0] + self.assertIsNone(prev.get("disabled")) + self.assertIsNotNone(nxt.get("disabled")) + self.assertEqual((prev.get("data-page"), nxt.get("data-page")), ("0", "2")) + + def test_status_text_is_marked_for_live_region(self): + """The range summary and the empty-result message carry the status marker the widget mirrors.""" + wizard = self._wizard() + self.assertEqual(self._status_text(self._render(wizard, PREFIX)), "1-10 of 12") + self.assertEqual(self._status_text(self._render(wizard, PREFIX, page=1)), "11-12 of 12") + self.assertEqual(self._status_text(self._render(wizard, f"{PREFIX} nothing matches")), "No registrants found.") + + def test_aria_label_escapes_name(self): + """A name containing markup is escaped in the accessible name as it is in the cell.""" + self.partner_model.create({"name": f"{PREFIX} bold", "is_registrant": True, "is_group": False}) + wizard = self._wizard() + wizard.search_text = f"{PREFIX} " + wizard._onchange_search_text() + raw = str(wizard.search_results_html) + + self.assertIn(f'aria-label="{PREFIX} <b>bold</b>, Individual"', raw) + self.assertNotIn("bold", raw) + + def test_selected_partner_bridge_sets_registrant(self): + """The integer the widget writes on Enter/click becomes the registrant.""" + wizard = self._wizard() + chosen = self.individuals[3] + wizard._selected_partner_id = chosen.id + wizard._onchange_selected_partner() + self.assertEqual(wizard.registrant_id, chosen) + + def test_page_change_rerenders_and_clamps(self): + """Paging shows the remaining rows, never the group, and an out-of-range page falls back to the last one.""" + wizard = self._wizard() + first_page = {row.get("data-partner-name") for row in self._rows(self._render(wizard, PREFIX))} + second_page = {row.get("data-partner-name") for row in self._rows(self._render(wizard, PREFIX, page=1))} + + self.assertEqual(len(first_page), 10) + self.assertEqual(len(second_page), 2) + self.assertEqual(first_page | second_page, set(self.individuals.mapped("name"))) + self.assertNotIn(self.search_group.name, first_page | second_page) + + doc = self._render(wizard, PREFIX, page=99) + self.assertEqual(len(self._rows(doc)), 2) + self.assertEqual(self._status_text(doc), "11-12 of 12") diff --git a/spp_change_request_v2/wizards/create_wizard.py b/spp_change_request_v2/wizards/create_wizard.py index 2f1c25438..9155abefa 100644 --- a/spp_change_request_v2/wizards/create_wizard.py +++ b/spp_change_request_v2/wizards/create_wizard.py @@ -280,7 +280,7 @@ def _render_search_results(self): total = self.env["res.partner"].search_count(search_domain) if not total: - self.search_results_html = Markup("

No registrants found.

") + self.search_results_html = Markup("

No registrants found.

") return page = self._search_page or 0 @@ -291,42 +291,65 @@ def _render_search_results(self): offset = page * page_size partners = self.env["res.partner"].search(search_domain, limit=page_size, offset=offset) + # Each row is an option of a listbox so keyboard and screen-reader + # users can reach and pick it; the widget handles the keys. Only the + # first row is in the Tab order (roving tabindex), the arrow keys move + # between rows. The aria-label repeats what the two cells show because + # an option's children are presentational to assistive technology. rows = [] - for p in partners: - ptype = ' Group' if p.is_group else ' Individual' + for index, p in enumerate(partners): + type_label, type_icon = ("Group", "fa-users") if p.is_group else ("Individual", "fa-user") + name = escape(p.name or "") rows.append( Markup( '' "{}" - "{}" + ' {}' ).format( + "0" if index == 0 else "-1", + name, + type_label, p.id, - escape(p.name or ""), - escape(p.name or ""), - Markup(ptype), + name, + name, + type_icon, + type_label, ) ) table = Markup( '' "" - "{}
NameType
" + '{}' ).format(Markup("").join(rows)) - # Pagination header + # Pagination header. Previous/Next are real buttons so they are + # focusable and respond to Enter/Space natively; a real `disabled` + # attribute (not just muted styling) takes the edge out of the Tab order. + # The range summary carries o_cr_search_status so the widget can mirror + # it into a live region. start = offset + 1 end = min(offset + page_size, total) - prev_cls = "text-muted" if page == 0 else "o_cr_page_prev" - next_cls = "text-muted" if page >= max_page else "o_cr_page_next" pagination = Markup( '
" - ).format(start, end, total, prev_cls, page - 1, next_cls, page + 1) + ).format( + start, + end, + total, + page - 1, + " disabled" if page == 0 else "", + page + 1, + " disabled" if page >= max_page else "", + ) self.search_results_html = pagination + table From 1a9611aa996314ab3663f4f1db139071a67e9ce4 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 16:44:07 +0800 Subject: [PATCH 02/15] fix(change_request): keyboard handling, focus management and live region for the search results widget (#580) Enter/Space select the focused row, ArrowUp/ArrowDown/Home/End move between rows with a roving tabindex, and clicks keep working through one delegated listener instead of per-row handlers re-attached after every patch. Every search or page change re-renders the whole results blob, which destroys the focused node, so focus is put back on the pager button that was pressed (or the other one, or the first row) once the form has re-rendered; selecting a row hides the results block and unmounts the widget, so focus is handed to the "Change Registrant" button instead of dropping to . A visually-hidden live region outside the re-rendered blob announces the result range and the empty-result message. The debounced search input now carries the field id so its "Search Registrant" label names it rather than the placeholder, and a focus ring is added for rows since the table-hover shading swallows the default one. --- .../static/src/css/cr_search_results.css | 6 + .../static/src/js/cr_search_results_field.js | 199 +++++++++++++++--- .../src/xml/cr_search_results_field.xml | 15 +- .../static/src/xml/search_delay_field.xml | 1 + 4 files changed, 187 insertions(+), 34 deletions(-) diff --git a/spp_change_request_v2/static/src/css/cr_search_results.css b/spp_change_request_v2/static/src/css/cr_search_results.css index 166021a0c..7c1b5c4af 100644 --- a/spp_change_request_v2/static/src/css/cr_search_results.css +++ b/spp_change_request_v2/static/src/css/cr_search_results.css @@ -5,3 +5,9 @@ max-width: 100% !important; display: block !important; } + +/* Keyboard focus on a result row must stay visible over the table-hover shading */ +.o_field_cr_search_results tr.o_cr_search_result:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: -2px; +} diff --git a/spp_change_request_v2/static/src/js/cr_search_results_field.js b/spp_change_request_v2/static/src/js/cr_search_results_field.js index 024c96574..2c46c91c7 100644 --- a/spp_change_request_v2/static/src/js/cr_search_results_field.js +++ b/spp_change_request_v2/static/src/js/cr_search_results_field.js @@ -5,11 +5,20 @@ import {_t} from "@web/core/l10n/translation"; import {registry} from "@web/core/registry"; import {standardFieldProps} from "@web/views/fields/standard_field_props"; +const ROW_SELECTOR = ".o_cr_search_result"; +const PAGER_SELECTOR = ".o_cr_page_prev, .o_cr_page_next"; +const STATUS_SELECTOR = ".o_cr_search_status"; +const CLEAR_BUTTON_SELECTOR = "button[name='action_clear_registrant']"; +// Upper bound on waiting for the form to re-render after a record update. +const RENDER_WAIT_MS = 500; + /** - * Custom widget that renders HTML search results and handles row clicks. - * When a row with class "o_cr_search_result" is clicked, it writes the - * partner ID to the _selected_partner_id bridge field, which triggers - * a server-side onchange to set registrant_id. + * Custom widget that renders HTML search results and handles row selection + * and paging by mouse or keyboard. The rows are options of a listbox: Enter + * or Space selects the focused row, ArrowUp/ArrowDown/Home/End move between + * rows. Selecting a row writes the partner ID to the _selected_partner_id + * bridge field, which triggers a server-side onchange to set registrant_id; + * paging writes _search_page the same way. */ export class CrSearchResultsField extends Component { static template = "spp_change_request_v2.CrSearchResultsField"; @@ -17,39 +26,173 @@ export class CrSearchResultsField extends Component { setup() { this.containerRef = useRef("container"); - onMounted(() => this._attachClickHandler()); - onPatched(() => this._attachClickHandler()); + this.statusRef = useRef("status"); + this.resolveNextPatch = null; + onMounted(() => { + // One delegated listener each: the server re-renders the whole + // results blob on every search or page change, so per-row handlers + // would have to be re-attached after each patch. + this.containerRef.el.addEventListener("click", (ev) => this._onClick(ev)); + this.containerRef.el.addEventListener("keydown", (ev) => + this._onKeydown(ev) + ); + this._announceStatus(); + }); + onPatched(() => { + this._announceStatus(); + if (this.resolveNextPatch) { + const resolve = this.resolveNextPatch; + this.resolveNextPatch = null; + resolve(); + } + }); } get htmlContent() { return this.props.record.data[this.props.name] || ""; } - _attachClickHandler() { - const el = this.containerRef.el; - if (!el) return; - // Row selection - el.querySelectorAll(".o_cr_search_result").forEach((row) => { - row.onclick = (ev) => { + get rows() { + return [...this.containerRef.el.querySelectorAll(ROW_SELECTOR)]; + } + + _onClick(ev) { + const row = ev.target.closest(ROW_SELECTOR); + if (row) { + ev.preventDefault(); + ev.stopPropagation(); + this._selectRow(row); + return; + } + const pager = ev.target.closest(PAGER_SELECTOR); + if (pager && !pager.disabled) { + ev.preventDefault(); + ev.stopPropagation(); + this._goToPage(pager); + } + } + + _onKeydown(ev) { + // The pager buttons are native buttons: Enter and Space already click them. + const row = ev.target.closest(ROW_SELECTOR); + if (!row) { + return; + } + const rows = this.rows; + const index = rows.indexOf(row); + let target = null; + switch (ev.key) { + case "Enter": + case " ": ev.preventDefault(); - ev.stopPropagation(); - const partnerId = parseInt(row.dataset.partnerId, 10); - if (partnerId) { - this.props.record.update({_selected_partner_id: partnerId}); - } - }; + this._selectRow(row); + return; + case "ArrowDown": + target = rows[Math.min(index + 1, rows.length - 1)]; + break; + case "ArrowUp": + target = rows[Math.max(index - 1, 0)]; + break; + case "Home": + target = rows[0]; + break; + case "End": + target = rows[rows.length - 1]; + break; + default: + return; + } + ev.preventDefault(); + this._focusRow(target, rows); + } + + /** + * Roving tabindex: the list is a single Tab stop, and the row that holds + * focus is the one Tab returns to. + */ + _focusRow(row, rows) { + for (const other of rows) { + other.tabIndex = -1; + } + row.tabIndex = 0; + row.focus(); + } + + async _selectRow(row) { + const partnerId = parseInt(row.dataset.partnerId, 10); + if (!partnerId) { + return; + } + // Setting the registrant hides the results block, which unmounts this + // widget, so focus is handed to the button that brings the search back. + const formEl = this.containerRef.el.closest(".o_form_view"); + await this.props.record.update({_selected_partner_id: partnerId}); + await this._focusWhenRendered(formEl, [CLEAR_BUTTON_SELECTOR]); + } + + async _goToPage(button) { + const page = parseInt(button.dataset.page, 10); + if (isNaN(page) || page < 0) { + return; + } + const pressed = button.classList.contains("o_cr_page_next") + ? ".o_cr_page_next" + : ".o_cr_page_prev"; + const other = + pressed === ".o_cr_page_next" ? ".o_cr_page_prev" : ".o_cr_page_next"; + const rendered = this._nextPatch(); + await this.props.record.update({_search_page: page}); + await rendered; + // Keep focus on the button that was pressed so it can be pressed again; + // on the last page it is disabled, so fall back to the other one, then + // to the first row. + await this._focusWhenRendered(this.containerRef.el, [ + pressed, + other, + ROW_SELECTOR, + ]); + } + + _nextPatch() { + return new Promise((resolve) => { + this.resolveNextPatch = resolve; + setTimeout(resolve, RENDER_WAIT_MS); }); - // Pagination - el.querySelectorAll(".o_cr_page_prev, .o_cr_page_next").forEach((link) => { - link.onclick = (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const page = parseInt(link.dataset.page); - if (!isNaN(page) && page >= 0) { - this.props.record.update({_search_page: page}); + } + + /** + * Focus the first enabled element matching one of the selectors, polling a + * few animation frames because the element may only appear once the form + * has re-rendered with the onchange result. + */ + async _focusWhenRendered(root, selectors) { + if (!root) { + return; + } + const deadline = Date.now() + RENDER_WAIT_MS; + while (Date.now() < deadline) { + for (const selector of selectors) { + const target = root.querySelector(selector); + if (target && !target.disabled) { + target.focus(); + return; } - }; - }); + } + await new Promise(requestAnimationFrame); + } + } + + /** + * Mirror the range summary ("1-10 of 23") or the empty-result message into + * a live region that outlives the re-rendered blob, so screen readers hear + * the outcome of a search or a page change. + */ + _announceStatus() { + const status = this.containerRef.el.querySelector(STATUS_SELECTOR); + const text = status ? status.textContent.trim() : ""; + if (this.statusRef.el && this.statusRef.el.textContent !== text) { + this.statusRef.el.textContent = text; + } } } diff --git a/spp_change_request_v2/static/src/xml/cr_search_results_field.xml b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml index b4ea3f52c..3b8f823d9 100644 --- a/spp_change_request_v2/static/src/xml/cr_search_results_field.xml +++ b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml @@ -2,12 +2,15 @@ -
+
+ +
+
diff --git a/spp_change_request_v2/static/src/xml/search_delay_field.xml b/spp_change_request_v2/static/src/xml/search_delay_field.xml index 5e5ca56f5..df507f373 100644 --- a/spp_change_request_v2/static/src/xml/search_delay_field.xml +++ b/spp_change_request_v2/static/src/xml/search_delay_field.xml @@ -4,6 +4,7 @@ Date: Fri, 2 Oct 2026 16:44:07 +0800 Subject: [PATCH 03/15] test(e2e): select the CR wizard registrant by keyboard only in test 18 (#580) The role locator proves role and accessible name, focus()/toBeFocused() proves the row is focusable, Enter exercises the keydown path instead of click, and the final assertion proves focus is handed to "Change Registrant" once the results disappear. Tests 16 and 17 keep the mouse path so both stay covered. --- e2e/tests/01-spp-starter-spmis.spec.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/e2e/tests/01-spp-starter-spmis.spec.ts b/e2e/tests/01-spp-starter-spmis.spec.ts index 0c946ba5f..b3fa974f8 100644 --- a/e2e/tests/01-spp-starter-spmis.spec.ts +++ b/e2e/tests/01-spp-starter-spmis.spec.ts @@ -18,7 +18,7 @@ // 15 - Manually defines 5 more Change Request Document Type vocabulary codes for the Philippines (BIR Form 2316, Academic Calendar, Authorization Letter, Certificate of Enrolment, Valid ID of Parent), matching the e2e fixture PDFs in e2e/fixtures/, by adding rows to the same vocabulary's Codes tab as test 14 // 16 - Creates an "Edit Individual Information" change request for Santos, Jose Miguel (as admin), uploads a supporting document, and submits it for approval // 17 - Creates an "Edit Group Information" change request for Santos Family (as admin), uploads a supporting document, and submits it for approval -// 18 - Creates an "Update ID Document" change request for Santos, Jose Miguel (as admin), sets a National ID with tomorrow's expiry date, uploads a supporting document, and submits it for approval +// 18 - Creates an "Update ID Document" change request for Santos, Jose Miguel (as admin), selecting the registrant by keyboard only, sets a National ID with tomorrow's expiry date, uploads a supporting document, and submits it for approval // 19 - Creates an HQ validator user (hqval@mail.com) with the "CR HQ Validator" role and sets its password // 20 - Logs in as the HQ validator and resolves all three pending change requests: approves // Edit Individual Information, rejects Edit Group Information (with a reason), and @@ -1127,10 +1127,19 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { console.log("✅ Request Type: Update ID Document"); await page.getByRole("textbox", {name: "Enter name or ID number..."}).fill("sant"); - await page - .locator("tr.o_cr_search_result", {hasText: "SANTOS, JOSE MIGUEL"}) - .click(); - console.log("✅ Registrant selected: SANTOS, JOSE MIGUEL"); + // Keyboard path (#580): the result rows are listbox options. The role + // locator proves role + accessible name, focus()/toBeFocused() proves the + // row is focusable, Enter exercises the keydown handler rather than click, + // and the last assertion proves focus is handed to "Change Registrant" + // once the results block disappears. Tests 16 and 17 keep the mouse path. + const santosRow = page.getByRole("option", { + name: "SANTOS, JOSE MIGUEL, Individual", + }); + await santosRow.focus(); + await expect(santosRow).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("button", {name: /Change Registrant/})).toBeFocused(); + console.log("✅ Registrant selected by keyboard: SANTOS, JOSE MIGUEL"); await page.getByRole("button", {name: "Create"}).click(); await page.waitForLoadState("domcontentloaded"); From ab2c732b20ad95a464ccb9fed995da1022262a5d Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 16:44:07 +0800 Subject: [PATCH 04/15] chore(change_request): bump to 19.0.3.1.17 with HISTORY fragment (#580) README.rst and static/description/index.html are left for CI's pinned generator, whose output is applied verbatim in a follow-up commit. --- spp_change_request_v2/__manifest__.py | 2 +- spp_change_request_v2/readme/HISTORY.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index b09a537a5..80f9dd341 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP Change Request V2", - "version": "19.0.3.1.16", + "version": "19.0.3.1.17", "sequence": 50, "category": "OpenSPP", "summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention", diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 4b8652f35..2016f4daa 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.17 + +- fix(change_request): the registrant search results in the Create Change Request wizard can be used from the keyboard and are announced by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The result rows were plain table rows with a mouse click handler, so a keyboard user could type a search but never pick a result, and the Previous/Next pager was made of links without a target that cannot take focus at all. Rows are now options of a listbox — Tab reaches the list, the arrow keys move between rows, Enter or Space selects — each carrying the registrant's name and type as its accessible name; the pager is made of real buttons whose out-of-range side is disabled rather than merely greyed; the result range and the empty-result message are mirrored into a live region; focus is kept on the pager button after a page change and handed to "Change Registrant" after a selection, instead of being dropped when the results re-render. The search box is also now labelled by its "Search Registrant" label rather than by its placeholder, and the decorative type icons are hidden from assistive technology. The inline results table looks and behaves the same for mouse users (#580) + ### 19.0.3.1.16 - fix(change_request): an Edit Individual change request can be opened again on a registrant that already holds a future date of birth. `spp.change.request.create` prefills the detail from the registrant and that prefill is a write, so the guard added in 19.0.3.1.15 refused the copied value and the request could not be created at all — closing the very path field staff use to correct the date. A birthdate the guard would refuse is now dropped from the prefill mapping rather than offered, so the field arrives empty and a valid date has to be entered. The rule itself lives in one place (`_is_future_birthdate` on the mixin), so what prefill declines to offer and what the constraint refuses cannot drift apart. From c454b51fddeab64c2589a428d11d9dbd12fa9753 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 17:01:45 +0800 Subject: [PATCH 05/15] fix(change_request): presentational table, set position and decorative pager arrows for the search results (#580) Review round. A listbox inside an exposed left the header row as a table with no data cells, so the table is now role="presentation" and the aria-hidden (sighted users still see the headings). Each option carries aria-setsize/aria-posinset so its position is announced within the whole result set rather than within the page, the listbox is named "Registrant search results", and the pager arrows are hidden from assistive technology so the buttons read as "Previous" and "Next". Tests cover the new attributes, a group row (label and icon) and the decorative arrows; the HISTORY fragment describes the table change. --- spp_change_request_v2/readme/HISTORY.md | 2 +- .../tests/test_create_wizard_search.py | 26 +++++++++++++++++++ .../wizards/create_wizard.py | 21 ++++++++++----- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 2016f4daa..31f867818 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,6 +1,6 @@ ### 19.0.3.1.17 -- fix(change_request): the registrant search results in the Create Change Request wizard can be used from the keyboard and are announced by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The result rows were plain table rows with a mouse click handler, so a keyboard user could type a search but never pick a result, and the Previous/Next pager was made of links without a target that cannot take focus at all. Rows are now options of a listbox — Tab reaches the list, the arrow keys move between rows, Enter or Space selects — each carrying the registrant's name and type as its accessible name; the pager is made of real buttons whose out-of-range side is disabled rather than merely greyed; the result range and the empty-result message are mirrored into a live region; focus is kept on the pager button after a page change and handed to "Change Registrant" after a selection, instead of being dropped when the results re-render. The search box is also now labelled by its "Search Registrant" label rather than by its placeholder, and the decorative type icons are hidden from assistive technology. The inline results table looks and behaves the same for mouse users (#580) +- fix(change_request): the registrant search results in the Create Change Request wizard can be used from the keyboard and are announced by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The result rows were plain table rows with a mouse click handler, so a keyboard user could type a search but never pick a result, and the Previous/Next pager was made of links without a target that cannot take focus at all. Rows are now options of a listbox — Tab reaches the list, the arrow keys move between rows, Enter or Space selects — each carrying the registrant's name and type as its accessible name and its position in the whole result set, while the table around them is presentational for assistive technology so its header row is not announced as an empty table; the pager is made of real buttons whose out-of-range side is disabled rather than merely greyed; the result range and the empty-result message are mirrored into a live region; focus is kept on the pager button after a page change and handed to "Change Registrant" after a selection, instead of being dropped when the results re-render. The search box is also now labelled by its "Search Registrant" label rather than by its placeholder, and the decorative type icons are hidden from assistive technology. The inline results table looks and behaves the same for mouse users (#580) ### 19.0.3.1.16 diff --git a/spp_change_request_v2/tests/test_create_wizard_search.py b/spp_change_request_v2/tests/test_create_wizard_search.py index e84cedfda..3c0406a4d 100644 --- a/spp_change_request_v2/tests/test_create_wizard_search.py +++ b/spp_change_request_v2/tests/test_create_wizard_search.py @@ -56,14 +56,30 @@ def test_rows_are_focusable_options(self): listbox = doc.xpath('.//tbody[@role="listbox"]') self.assertEqual(len(listbox), 1) self.assertTrue(listbox[0].get("aria-label")) + # The table itself is layout for assistive technology, otherwise the + # header row is a table with no data cells. + self.assertEqual(doc.xpath(".//table")[0].get("role"), "presentation") + self.assertEqual(doc.xpath(".//thead")[0].get("aria-hidden"), "true") rows = self._rows(doc) self.assertEqual(len(rows), 10) self.assertEqual([row.get("role") for row in rows], ["option"] * 10) self.assertEqual([row.get("tabindex") for row in rows], ["0"] + ["-1"] * 9) + self.assertEqual([row.get("aria-setsize") for row in rows], ["12"] * 10) + self.assertEqual([row.get("aria-posinset") for row in rows], [str(n) for n in range(1, 11)]) for row in rows: self.assertEqual(row.get("aria-label"), f"{row.get('data-partner-name')}, Individual") + def test_group_row_is_labelled_as_group(self): + """A group result carries the group label and icon in the same shape as an individual.""" + wizard = self.wizard_model.create({"request_type_id": self.cr_type_add_member.id}) + doc = self._render(wizard, f"{PREFIX} Group") + rows = self._rows(doc) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0].get("aria-label"), f"{self.search_group.name}, Group") + self.assertEqual(rows[0].xpath(".//i")[0].get("class"), "fa fa-users") + self.assertEqual(rows[0].xpath(".//i")[0].get("aria-hidden"), "true") + def test_type_icon_is_hidden_from_assistive_tech(self): """Decorative type icons are not announced.""" doc = self._render(self._wizard(), PREFIX) @@ -131,3 +147,13 @@ def test_page_change_rerenders_and_clamps(self): doc = self._render(wizard, PREFIX, page=99) self.assertEqual(len(self._rows(doc)), 2) self.assertEqual(self._status_text(doc), "11-12 of 12") + self.assertEqual([row.get("aria-posinset") for row in self._rows(doc)], ["11", "12"]) + + def test_pager_arrows_are_decorative(self): + """The arrow glyphs are hidden so the buttons read as "Previous" and "Next".""" + doc = self._render(self._wizard(), PREFIX) + for cls in ("o_cr_page_prev", "o_cr_page_next"): + button = doc.xpath(f'.//button[contains(@class, "{cls}")]')[0] + arrows = button.xpath('.//span[@aria-hidden="true"]') + self.assertEqual(len(arrows), 1) + self.assertIn(arrows[0].text.strip(), ("←", "→")) diff --git a/spp_change_request_v2/wizards/create_wizard.py b/spp_change_request_v2/wizards/create_wizard.py index 9155abefa..159e816c8 100644 --- a/spp_change_request_v2/wizards/create_wizard.py +++ b/spp_change_request_v2/wizards/create_wizard.py @@ -295,7 +295,9 @@ def _render_search_results(self): # users can reach and pick it; the widget handles the keys. Only the # first row is in the Tab order (roving tabindex), the arrow keys move # between rows. The aria-label repeats what the two cells show because - # an option's children are presentational to assistive technology. + # an option's children are presentational to assistive technology, and + # setsize/posinset give the position within the whole result set rather + # than within the page. rows = [] for index, p in enumerate(partners): type_label, type_icon = ("Group", "fa-users") if p.is_group else ("Individual", "fa-user") @@ -304,6 +306,7 @@ def _render_search_results(self): Markup( '' "" '' @@ -311,6 +314,8 @@ def _render_search_results(self): "0" if index == 0 else "-1", name, type_label, + total, + offset + index + 1, p.id, name, name, @@ -319,15 +324,19 @@ def _render_search_results(self): ) ) + # The table is layout only for assistive technology: the body is the + # listbox and the header row would otherwise be a table with no data + # cells. Sighted users still see the column headings. table = Markup( - '
{} {}
' - "" - '{}
NameType
' + '' + '' + '{}' ).format(Markup("").join(rows)) # Pagination header. Previous/Next are real buttons so they are # focusable and respond to Enter/Space natively; a real `disabled` # attribute (not just muted styling) takes the edge out of the Tab order. + # The arrows are decoration (screen readers would read "left arrow"). # The range summary carries o_cr_search_status so the widget can mirror # it into a live region. start = offset + 1 @@ -337,9 +346,9 @@ def _render_search_results(self): '{}-{} of {}' "
" '' + ' data-page="{}"{}>Previous' '' + ' data-page="{}"{}>Next' "
" ).format( start, From 9fb14bd1d4dd8017dbe934b20ef2c223c8b76446 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 17:01:45 +0800 Subject: [PATCH 06/15] fix(change_request): re-render-driven focus restore, form-hosted live region and hotkey hardening (#580) Review round. Focus after a page change was restored after a 500 ms timer that started before the onchange request was sent, so on a slow link it focused the old pager button just before the re-render removed it. The focus is now applied in onPatched when the results HTML actually changed, with a guarded fallback for an unchanged answer (page clamped to the last one). The live region moved out of the widget into the wizard form, where it exists before the first results arrive: a region inserted together with its text is not announced. The widget clears it before writing so an unchanged range text is announced again. Keys with Alt/Ctrl/Meta are left to the browser and to Odoo's hotkeys (Ctrl+Enter is the dialog's footer-button hotkey) and handled keys stop propagating. The roving tabindex follows focus that arrives by mouse or from assistive technology (focusin), not only the arrow keys. The focus ring did not paint at all: --bs-primary does not exist in Odoo 19's backend (Bootstrap is compiled without the prefix), which made the whole declaration invalid. It now uses --primary with a literal fallback, the cells carry the ring as a box-shadow because outlines on are unreliable under border-collapse, and the pager buttons get the same ring. The search box also gets autocomplete="off". --- .../static/src/css/cr_search_results.css | 21 ++- .../static/src/js/cr_search_results_field.js | 134 +++++++++++------- .../src/xml/cr_search_results_field.xml | 15 +- .../static/src/xml/search_delay_field.xml | 1 + .../views/create_wizard_views.xml | 11 ++ 5 files changed, 120 insertions(+), 62 deletions(-) diff --git a/spp_change_request_v2/static/src/css/cr_search_results.css b/spp_change_request_v2/static/src/css/cr_search_results.css index 7c1b5c4af..ed5996f59 100644 --- a/spp_change_request_v2/static/src/css/cr_search_results.css +++ b/spp_change_request_v2/static/src/css/cr_search_results.css @@ -6,8 +6,25 @@ display: block !important; } -/* Keyboard focus on a result row must stay visible over the table-hover shading */ +/* Keyboard focus on a result row must stay visible over the table-hover shading. + Odoo 19 compiles Bootstrap without the "bs-" prefix, so the theme colour is + --primary; the literal is the fallback for a theme that does not expose it. */ .o_field_cr_search_results tr.o_cr_search_result:focus-visible { - outline: 2px solid var(--bs-primary); + outline: 2px solid var(--primary, #714b67); outline-offset: -2px; } + +/* Outlines on are not painted reliably under border-collapse, so the cells carry + the ring too */ +.o_field_cr_search_results tr.o_cr_search_result:focus-visible > td { + box-shadow: + inset 0 2px 0 var(--primary, #714b67), + inset 0 -2px 0 var(--primary, #714b67); +} + +/* Link-styled pager buttons: the backend theme can suppress the default button ring */ +.o_field_cr_search_results .o_cr_page_prev:focus-visible, +.o_field_cr_search_results .o_cr_page_next:focus-visible { + outline: 2px solid var(--primary, #714b67); + outline-offset: 2px; +} diff --git a/spp_change_request_v2/static/src/js/cr_search_results_field.js b/spp_change_request_v2/static/src/js/cr_search_results_field.js index 2c46c91c7..08ae01b47 100644 --- a/spp_change_request_v2/static/src/js/cr_search_results_field.js +++ b/spp_change_request_v2/static/src/js/cr_search_results_field.js @@ -8,9 +8,12 @@ import {standardFieldProps} from "@web/views/fields/standard_field_props"; const ROW_SELECTOR = ".o_cr_search_result"; const PAGER_SELECTOR = ".o_cr_page_prev, .o_cr_page_next"; const STATUS_SELECTOR = ".o_cr_search_status"; +// Live region hosted by the wizard form, outside the results block, so it +// already exists when the first results arrive (see create_wizard_views.xml). +const LIVE_REGION_SELECTOR = ".o_cr_search_live"; const CLEAR_BUTTON_SELECTOR = "button[name='action_clear_registrant']"; // Upper bound on waiting for the form to re-render after a record update. -const RENDER_WAIT_MS = 500; +const RENDER_WAIT_MS = 2000; /** * Custom widget that renders HTML search results and handles row selection @@ -26,25 +29,30 @@ export class CrSearchResultsField extends Component { setup() { this.containerRef = useRef("container"); - this.statusRef = useRef("status"); - this.resolveNextPatch = null; + // HTML as last rendered, to tell a re-render with new results from + // any other patch of the form. + this.renderedHtml = null; + // Selectors to focus, in order of preference, once the results have + // been re-rendered after a page change. + this.pendingFocus = null; onMounted(() => { // One delegated listener each: the server re-renders the whole // results blob on every search or page change, so per-row handlers // would have to be re-attached after each patch. - this.containerRef.el.addEventListener("click", (ev) => this._onClick(ev)); - this.containerRef.el.addEventListener("keydown", (ev) => - this._onKeydown(ev) - ); + const el = this.containerRef.el; + el.addEventListener("click", (ev) => this._onClick(ev)); + el.addEventListener("keydown", (ev) => this._onKeydown(ev)); + el.addEventListener("focusin", (ev) => this._onFocusin(ev)); + this.renderedHtml = this.htmlContent; this._announceStatus(); }); onPatched(() => { - this._announceStatus(); - if (this.resolveNextPatch) { - const resolve = this.resolveNextPatch; - this.resolveNextPatch = null; - resolve(); + if (this.htmlContent === this.renderedHtml) { + return; } + this.renderedHtml = this.htmlContent; + this._announceStatus(); + this._applyPendingFocus(); }); } @@ -73,9 +81,11 @@ export class CrSearchResultsField extends Component { } _onKeydown(ev) { - // The pager buttons are native buttons: Enter and Space already click them. + // The pager buttons are native buttons: Enter and Space already click + // them. Keys with a modifier belong to the browser or to Odoo's hotkeys + // (the dialog binds Ctrl+Enter to its first footer button). const row = ev.target.closest(ROW_SELECTOR); - if (!row) { + if (!row || ev.altKey || ev.ctrlKey || ev.metaKey) { return; } const rows = this.rows; @@ -85,6 +95,7 @@ export class CrSearchResultsField extends Component { case "Enter": case " ": ev.preventDefault(); + ev.stopPropagation(); this._selectRow(row); return; case "ArrowDown": @@ -103,19 +114,23 @@ export class CrSearchResultsField extends Component { return; } ev.preventDefault(); - this._focusRow(target, rows); + ev.stopPropagation(); + target.focus(); } /** * Roving tabindex: the list is a single Tab stop, and the row that holds - * focus is the one Tab returns to. + * focus is the one Tab returns to. Handled on focusin so it also covers + * focus that arrives by mouse or from assistive technology. */ - _focusRow(row, rows) { - for (const other of rows) { - other.tabIndex = -1; + _onFocusin(ev) { + const row = ev.target.closest(ROW_SELECTOR); + if (!row) { + return; + } + for (const other of this.rows) { + other.tabIndex = other === row ? 0 : -1; } - row.tabIndex = 0; - row.focus(); } async _selectRow(row) { @@ -127,7 +142,7 @@ export class CrSearchResultsField extends Component { // widget, so focus is handed to the button that brings the search back. const formEl = this.containerRef.el.closest(".o_form_view"); await this.props.record.update({_selected_partner_id: partnerId}); - await this._focusWhenRendered(formEl, [CLEAR_BUTTON_SELECTOR]); + await this._focusWhenRendered(formEl, CLEAR_BUTTON_SELECTOR); } async _goToPage(button) { @@ -135,48 +150,58 @@ export class CrSearchResultsField extends Component { if (isNaN(page) || page < 0) { return; } + // Keep focus on the button that was pressed so it can be pressed again; + // on the last page it is disabled, so fall back to the other one, then + // to the first row. The focus is applied by onPatched once the new + // results are in the DOM, however long the onchange takes. const pressed = button.classList.contains("o_cr_page_next") ? ".o_cr_page_next" : ".o_cr_page_prev"; const other = pressed === ".o_cr_page_next" ? ".o_cr_page_prev" : ".o_cr_page_next"; - const rendered = this._nextPatch(); + this.pendingFocus = [pressed, other, ROW_SELECTOR]; await this.props.record.update({_search_page: page}); - await rendered; - // Keep focus on the button that was pressed so it can be pressed again; - // on the last page it is disabled, so fall back to the other one, then - // to the first row. - await this._focusWhenRendered(this.containerRef.el, [ - pressed, - other, - ROW_SELECTOR, - ]); + // The server may answer with the same HTML (page clamped to the last + // one), in which case no patch with new results ever comes. Only then + // is the focus applied here; while a re-render is still due, onPatched + // owns it, otherwise the old button would be focused and then removed. + await new Promise(requestAnimationFrame); + if (this.htmlContent === this.renderedHtml) { + this._applyPendingFocus(); + } } - _nextPatch() { - return new Promise((resolve) => { - this.resolveNextPatch = resolve; - setTimeout(resolve, RENDER_WAIT_MS); - }); + _applyPendingFocus() { + const selectors = this.pendingFocus; + this.pendingFocus = null; + const el = this.containerRef.el; + if (!selectors || !el) { + return; + } + for (const selector of selectors) { + const target = el.querySelector(selector); + if (target && !target.disabled) { + target.focus(); + return; + } + } } /** - * Focus the first enabled element matching one of the selectors, polling a - * few animation frames because the element may only appear once the form - * has re-rendered with the onchange result. + * Focus the element matching the selector, polling animation frames + * because it only appears once the form has re-rendered with the onchange + * result. */ - async _focusWhenRendered(root, selectors) { + async _focusWhenRendered(root, selector) { if (!root) { return; } const deadline = Date.now() + RENDER_WAIT_MS; while (Date.now() < deadline) { - for (const selector of selectors) { - const target = root.querySelector(selector); - if (target && !target.disabled) { - target.focus(); - return; - } + const target = root.querySelector(selector); + if (target) { + target.focus(); + return; } await new Promise(requestAnimationFrame); } @@ -184,15 +209,22 @@ export class CrSearchResultsField extends Component { /** * Mirror the range summary ("1-10 of 23") or the empty-result message into - * a live region that outlives the re-rendered blob, so screen readers hear - * the outcome of a search or a page change. + * the form's live region, so screen readers hear the outcome of a search + * or a page change. The region is cleared first so that an unchanged text + * (a refined search with the same range) is announced again. */ _announceStatus() { const status = this.containerRef.el.querySelector(STATUS_SELECTOR); const text = status ? status.textContent.trim() : ""; - if (this.statusRef.el && this.statusRef.el.textContent !== text) { - this.statusRef.el.textContent = text; + const formEl = this.containerRef.el.closest(".o_form_view"); + const region = formEl && formEl.querySelector(LIVE_REGION_SELECTOR); + if (!region) { + return; } + region.textContent = ""; + requestAnimationFrame(() => { + region.textContent = text; + }); } } diff --git a/spp_change_request_v2/static/src/xml/cr_search_results_field.xml b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml index 3b8f823d9..b4ea3f52c 100644 --- a/spp_change_request_v2/static/src/xml/cr_search_results_field.xml +++ b/spp_change_request_v2/static/src/xml/cr_search_results_field.xml @@ -2,15 +2,12 @@ -
- -
-
+
diff --git a/spp_change_request_v2/static/src/xml/search_delay_field.xml b/spp_change_request_v2/static/src/xml/search_delay_field.xml index df507f373..3abcd2302 100644 --- a/spp_change_request_v2/static/src/xml/search_delay_field.xml +++ b/spp_change_request_v2/static/src/xml/search_delay_field.xml @@ -6,6 +6,7 @@ t-ref="input" t-att-id="props.id" type="text" + autocomplete="off" class="o_input" t-att-value="value" t-att-placeholder="props.placeholder || ''" diff --git a/spp_change_request_v2/views/create_wizard_views.xml b/spp_change_request_v2/views/create_wizard_views.xml index a577dae52..3075c508b 100644 --- a/spp_change_request_v2/views/create_wizard_views.xml +++ b/spp_change_request_v2/views/create_wizard_views.xml @@ -62,6 +62,17 @@ + +
+
Date: Fri, 2 Oct 2026 17:01:46 +0800 Subject: [PATCH 07/15] test(e2e): find the search box by its label; prove Tab and arrow navigation in test 18 (#580) Associating the "Search Registrant" label changed the input's accessible name, so the three locators that used the placeholder would no longer match. Test 18 now also proves that Tab from the search box reaches the list and that ArrowDown/Home move focus, and matches the option name exactly. --- e2e/tests/01-spp-starter-spmis.spec.ts | 28 +++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/e2e/tests/01-spp-starter-spmis.spec.ts b/e2e/tests/01-spp-starter-spmis.spec.ts index b3fa974f8..59a736780 100644 --- a/e2e/tests/01-spp-starter-spmis.spec.ts +++ b/e2e/tests/01-spp-starter-spmis.spec.ts @@ -998,7 +998,7 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { await page.getByText("Edit Individual Information").click(); console.log("✅ Request Type: Edit Individual Information"); - await page.getByRole("textbox", {name: "Enter name or ID number..."}).fill("san"); + await page.getByRole("textbox", {name: "Search Registrant"}).fill("san"); await page.getByRole("cell", {name: "SANTOS, JOSE MIGUEL"}).click(); console.log("✅ Registrant selected: SANTOS, JOSE MIGUEL"); @@ -1065,7 +1065,7 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { await page.getByText("Edit Group Information").click(); console.log("✅ Request Type: Edit Group Information"); - await page.getByRole("textbox", {name: "Enter name or ID number..."}).fill("san"); + await page.getByRole("textbox", {name: "Search Registrant"}).fill("san"); await page.getByRole("cell", {name: "Santos Family"}).click(); console.log("✅ Registrant selected: Santos Family"); @@ -1126,14 +1126,28 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { await page.getByText("Update ID Document").click(); console.log("✅ Request Type: Update ID Document"); - await page.getByRole("textbox", {name: "Enter name or ID number..."}).fill("sant"); + const searchBox = page.getByRole("textbox", {name: "Search Registrant"}); + await searchBox.fill("sant"); // Keyboard path (#580): the result rows are listbox options. The role - // locator proves role + accessible name, focus()/toBeFocused() proves the - // row is focusable, Enter exercises the keydown handler rather than click, - // and the last assertion proves focus is handed to "Change Registrant" - // once the results block disappears. Tests 16 and 17 keep the mouse path. + // locator proves role + accessible name; Tab from the search box proves the + // list is in the Tab order (fewer than 11 hits, so both pager buttons are + // disabled and skipped); ArrowDown proves the keydown handler moves focus; + // Enter exercises the keydown path rather than click, and the last + // assertion proves focus is handed to "Change Registrant" once the results + // block disappears. Tests 16 and 17 keep the mouse path. + const options = page.getByRole("option"); + await options.first().waitFor(); + await searchBox.press("Tab"); + await expect(options.first()).toBeFocused(); + if ((await options.count()) > 1) { + await page.keyboard.press("ArrowDown"); + await expect(options.nth(1)).toBeFocused(); + await page.keyboard.press("Home"); + await expect(options.first()).toBeFocused(); + } const santosRow = page.getByRole("option", { name: "SANTOS, JOSE MIGUEL, Individual", + exact: true, }); await santosRow.focus(); await expect(santosRow).toBeFocused(); From 0d647c0064c376db75d5cc88645eaf45ae1e15ea Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 17:08:36 +0800 Subject: [PATCH 08/15] docs(change_request): regenerate README from fragments (CI generator output) (#580) --- spp_change_request_v2/README.rst | 24 ++++++ .../static/description/index.html | 75 ++++++++++++------- 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 297815919..8e57b7232 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,30 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.17 +~~~~~~~~~~~ + +- fix(change_request): the registrant search results in the Create + Change Request wizard can be used from the keyboard and are announced + by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The + result rows were plain table rows with a mouse click handler, so a + keyboard user could type a search but never pick a result, and the + Previous/Next pager was made of links without a target that cannot + take focus at all. Rows are now options of a listbox — Tab reaches the + list, the arrow keys move between rows, Enter or Space selects — each + carrying the registrant's name and type as its accessible name and its + position in the whole result set, while the table around them is + presentational for assistive technology so its header row is not + announced as an empty table; the pager is made of real buttons whose + out-of-range side is disabled rather than merely greyed; the result + range and the empty-result message are mirrored into a live region; + focus is kept on the pager button after a page change and handed to + "Change Registrant" after a selection, instead of being dropped when + the results re-render. The search box is also now labelled by its + "Search Registrant" label rather than by its placeholder, and the + decorative type icons are hidden from assistive technology. The inline + results table looks and behaves the same for mouse users (#580) + 19.0.3.1.16 ~~~~~~~~~~~ diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 90a552098..1a06e39c7 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,31 @@

Changelog

+

19.0.3.1.17

+
    +
  • fix(change_request): the registrant search results in the Create +Change Request wizard can be used from the keyboard and are announced +by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The +result rows were plain table rows with a mouse click handler, so a +keyboard user could type a search but never pick a result, and the +Previous/Next pager was made of links without a target that cannot +take focus at all. Rows are now options of a listbox — Tab reaches the +list, the arrow keys move between rows, Enter or Space selects — each +carrying the registrant’s name and type as its accessible name and its +position in the whole result set, while the table around them is +presentational for assistive technology so its header row is not +announced as an empty table; the pager is made of real buttons whose +out-of-range side is disabled rather than merely greyed; the result +range and the empty-result message are mirrored into a live region; +focus is kept on the pager button after a page change and handed to +“Change Registrant” after a selection, instead of being dropped when +the results re-render. The search box is also now labelled by its +“Search Registrant” label rather than by its placeholder, and the +decorative type icons are hidden from assistive technology. The inline +results table looks and behaves the same for mouse users (#580)
  • +
+
+

19.0.3.1.16

  • fix(change_request): an Edit Individual change request can be opened @@ -1358,7 +1383,7 @@

    19.0.3.1.16

    which of several lines to fix.
-
+

19.0.3.1.15

  • fix(change_request): refuse a date of birth in the future while the @@ -1374,7 +1399,7 @@

    19.0.3.1.15

    recorded earlier that local day (#362)
-
+

19.0.3.1.14

  • fix(change_request): group-scope conflict rules work again. @@ -1391,7 +1416,7 @@

    19.0.3.1.14

    both directions, including that ended memberships are excluded.
-
+

19.0.3.1.13

  • fix(change_request): a selectable field on a dynamic-approval type may @@ -1408,7 +1433,7 @@

    19.0.3.1.13

    routing key is still not applied.
-
+

19.0.3.1.12

  • fix(change_request): auto-apply-on-approve runs through the public @@ -1423,7 +1448,7 @@

    19.0.3.1.12

    the applying user is still recorded as the approver.
-
+

19.0.3.1.11

  • fix(change_request): field-mapping transform expressions are evaluated @@ -1462,7 +1487,7 @@

    19.0.3.1.11

    the full traceback is logged only at DEBUG.
-
+

19.0.3.1.10

  • fix(security): conflict and duplicate detection now decide whether a @@ -1502,7 +1527,7 @@

    19.0.3.1.10

    configured mapping.
-
+

19.0.3.1.9

  • fix(security): duplicate detection now scores the fields both change @@ -1519,7 +1544,7 @@

    19.0.3.1.9

    requester-writable selected_field_name / field_to_modify.
-
+

19.0.3.1.8

  • fix(security): scope the Create-Group member wizards to the parent @@ -1537,7 +1562,7 @@

    19.0.3.1.8

    access-control entry grants.
-
+

19.0.3.1.7

  • fix(security): require change-request manager rights to apply a change @@ -1552,7 +1577,7 @@

    19.0.3.1.7

    endpoint.
-
+

19.0.3.1.6

  • fix(security): derive conflict and duplicate detection from the change @@ -1566,7 +1591,7 @@

    19.0.3.1.6

    an empty one, so detection cannot silently disable itself.
-
+

19.0.3.1.5

  • fix(security): scope the CR Requestor, Local Validator and HQ @@ -1578,7 +1603,7 @@

    19.0.3.1.5

    are noupdate.
-
+

19.0.3.1.4

  • fix(security): add ownership and area record rules to every concrete @@ -1595,7 +1620,7 @@

    19.0.3.1.4

    unrestricted delete their access-control entries grant.
-
+

19.0.3.1.3

  • fix(security): route and apply the same single field for @@ -1608,7 +1633,7 @@

    19.0.3.1.3

    the routing selector.
-
+

19.0.3.1.2

  • fix(change_request_v2): adding an ID now looks for a live one of that @@ -1617,7 +1642,7 @@

    19.0.3.1.2

    (#1136)
-
+

19.0.3.1.1

  • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1631,7 +1656,7 @@

    19.0.3.1.1

    applied) so the constraint applies cleanly on upgrade.
-
+

19.0.3.1.0

  • revert(change_request): restore the create-a-new-individual Add @@ -1649,7 +1674,7 @@

    19.0.3.1.0

    not restored here; reinstate separately if needed.
-
+

19.0.3.0.0

  • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1671,7 +1696,7 @@

    19.0.3.0.0

    must adapt (see #1133).
-
+

19.0.2.0.8

  • fix(views): disable inline creation of CR document types on the Change @@ -1682,7 +1707,7 @@

    19.0.2.0.8

    Documents” modal (missing Name field) that blocked saving (#1125)
-
+

19.0.2.0.7

  • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1694,7 +1719,7 @@

    19.0.2.0.7

    dependencies.
-
+

19.0.2.0.6

  • fix(views): route post-submit CRs (pending / approved / applied / @@ -1709,7 +1734,7 @@

    19.0.2.0.6

    list so row-click goes through the stage router.
-
+

19.0.2.0.5

  • fix(security): add a global ir.rule on spp.change.request that @@ -1722,27 +1747,27 @@

    19.0.2.0.5

    roles).
-
+

19.0.2.0.3

  • fix: add HTML escaping to all computed Html fields with sanitize=False to prevent stored XSS (#50)
-
+

19.0.2.0.2

  • fix: fix batch approval wizard line deletion (#130)
-
+

19.0.2.0.1

  • fix: skip field types before getattr and isolate detail prefetch (#129)
-
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • From bf35abf794d88545683b3802c57a3d0e78a1439c Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 17:23:32 +0800 Subject: [PATCH 09/15] test(e2e): address result rows by their option name in tests 16 and 17 (#580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatched E2E run hung in test 16 at getByRole("cell", …): a cell inside a tr role="option" is presentational to the accessibility tree, so the locator never resolved. Both mouse-path tests now click the row by its option name, as test 18 does. --- e2e/tests/01-spp-starter-spmis.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/e2e/tests/01-spp-starter-spmis.spec.ts b/e2e/tests/01-spp-starter-spmis.spec.ts index 59a736780..765f7c3d7 100644 --- a/e2e/tests/01-spp-starter-spmis.spec.ts +++ b/e2e/tests/01-spp-starter-spmis.spec.ts @@ -999,7 +999,11 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { console.log("✅ Request Type: Edit Individual Information"); await page.getByRole("textbox", {name: "Search Registrant"}).fill("san"); - await page.getByRole("cell", {name: "SANTOS, JOSE MIGUEL"}).click(); + // Result rows are listbox options (#580); their cells are presentational to + // the accessibility tree, so the row is addressed by its option name. + await page + .getByRole("option", {name: "SANTOS, JOSE MIGUEL, Individual", exact: true}) + .click(); console.log("✅ Registrant selected: SANTOS, JOSE MIGUEL"); await page.getByRole("button", {name: "Create"}).click(); @@ -1066,7 +1070,7 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { console.log("✅ Request Type: Edit Group Information"); await page.getByRole("textbox", {name: "Search Registrant"}).fill("san"); - await page.getByRole("cell", {name: "Santos Family"}).click(); + await page.getByRole("option", {name: "Santos Family, Group", exact: true}).click(); console.log("✅ Registrant selected: Santos Family"); await page.getByRole("button", {name: "Create"}).click(); From 3e17040586828462cca06cd72584b29279709ae9 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 17:51:23 +0800 Subject: [PATCH 10/15] fix(change_request): leave focus alone when the user moved it during a search request (#580) Restoring focus after a page change, or handing it to "Change Registrant" after a selection, now happens only when focus was lost with the removed results (it sits on body) or still sits inside them. A user who pressed Next and went back to typing in the search box before the response arrived keeps the search box; a user who moved to another field while a slow selection was in flight keeps that field. Verified with 1.5 s emulated latency. The results HTML is compared as text rather than by object identity, so a Markup instance handed out anew for an unchanged value does not count as a re-render. A test pins that quotes and angle brackets in a registrant's name round trip through the aria-label, data-partner-name and the cell unchanged. --- .../static/src/js/cr_search_results_field.js | 25 ++++++++++++++++--- .../tests/test_create_wizard_search.py | 10 ++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/spp_change_request_v2/static/src/js/cr_search_results_field.js b/spp_change_request_v2/static/src/js/cr_search_results_field.js index 08ae01b47..cdbebb18e 100644 --- a/spp_change_request_v2/static/src/js/cr_search_results_field.js +++ b/spp_change_request_v2/static/src/js/cr_search_results_field.js @@ -47,7 +47,9 @@ export class CrSearchResultsField extends Component { this._announceStatus(); }); onPatched(() => { - if (this.htmlContent === this.renderedHtml) { + // Compared as text: the record may hand out a new Markup object + // for an unchanged value. + if (String(this.htmlContent) === String(this.renderedHtml)) { return; } this.renderedHtml = this.htmlContent; @@ -166,16 +168,30 @@ export class CrSearchResultsField extends Component { // is the focus applied here; while a re-render is still due, onPatched // owns it, otherwise the old button would be focused and then removed. await new Promise(requestAnimationFrame); - if (this.htmlContent === this.renderedHtml) { + if (String(this.htmlContent) === String(this.renderedHtml)) { this._applyPendingFocus(); } } + /** + * True when the user has put focus somewhere of their own (for example + * back in the search box) while a request was in flight: focus is then + * left alone. Focus that was lost with the removed results sits on body. + */ + _userMovedFocus() { + const active = document.activeElement; + if (!active || active === document.body) { + return false; + } + const el = this.containerRef.el; + return !(el && el.contains(active)); + } + _applyPendingFocus() { const selectors = this.pendingFocus; this.pendingFocus = null; const el = this.containerRef.el; - if (!selectors || !el) { + if (!selectors || !el || this._userMovedFocus()) { return; } for (const selector of selectors) { @@ -198,6 +214,9 @@ export class CrSearchResultsField extends Component { } const deadline = Date.now() + RENDER_WAIT_MS; while (Date.now() < deadline) { + if (this._userMovedFocus()) { + return; + } const target = root.querySelector(selector); if (target) { target.focus(); diff --git a/spp_change_request_v2/tests/test_create_wizard_search.py b/spp_change_request_v2/tests/test_create_wizard_search.py index 3c0406a4d..d71c8ea98 100644 --- a/spp_change_request_v2/tests/test_create_wizard_search.py +++ b/spp_change_request_v2/tests/test_create_wizard_search.py @@ -125,6 +125,16 @@ def test_aria_label_escapes_name(self): self.assertIn(f'aria-label="{PREFIX} <b>bold</b>, Individual"', raw) self.assertNotIn("bold", raw) + def test_aria_label_round_trips_quotes(self): + """Quotes in a name cannot break out of the attribute: the parsed label is the name itself.""" + name = f"""{PREFIX} O'Neil "Quoted" """ + self.partner_model.create({"name": name, "is_registrant": True, "is_group": False}) + wizard = self._wizard() + row = self._rows(self._render(wizard, f"{PREFIX} O'Neil"))[0] + self.assertEqual(row.get("aria-label"), f"{name}, Individual") + self.assertEqual(row.get("data-partner-name"), name) + self.assertEqual(row.xpath("./td")[0].text, name) + def test_selected_partner_bridge_sets_registrant(self): """The integer the widget writes on Enter/click becomes the registrant.""" wizard = self._wizard() From b9ba3d2167bf799cc0097993ceb3df07fdb55284 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 2 Oct 2026 18:00:13 +0800 Subject: [PATCH 11/15] fix(change_request): confirm the selection, keep the hand-off under browse-mode activation, translatable search strings (#580) Second review round, verified on a live stack. A screen reader in browse mode activates a row without moving system focus, and a user may click into the search box while a slow selection is in flight; in both cases the search box disappears with the results once a registrant is set. Whether focus was "moved by the user" is now decided only after the form has re-rendered, against the element that had focus when the action started, so focus is handed to "Change Registrant" whenever it would otherwise land on body, and left alone when the user is in a field that survives. The selected-registrant card no longer carries an alert role: an assertive alert inserted in the same frame as the focus move is cut off by that move. The choice is confirmed instead through the live region ("Selected: NAME, Individual") after focus has settled, and the region now survives a selection. The request-type hint is a status rather than an alert so it does not interrupt on every change of the type field, and the region is cleared when the results go away. The pager buttons are named "Previous/Next page of results" so a bare "Next" is not taken for a wizard step, the card icons are hidden from assistive technology like the row icons, and every string the search and the card render is wrapped in _(). Tests: a CR requestor (role, record rules in force) gets the same results and can select; pager accessible names. --- spp_change_request_v2/readme/HISTORY.md | 2 +- .../static/src/js/cr_search_results_field.js | 100 ++++++++++++------ .../tests/test_create_wizard_search.py | 23 ++++ .../views/create_wizard_views.xml | 18 ++-- .../wizards/create_wizard.py | 42 ++++---- 5 files changed, 128 insertions(+), 57 deletions(-) diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 31f867818..eaac7091d 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,6 +1,6 @@ ### 19.0.3.1.17 -- fix(change_request): the registrant search results in the Create Change Request wizard can be used from the keyboard and are announced by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The result rows were plain table rows with a mouse click handler, so a keyboard user could type a search but never pick a result, and the Previous/Next pager was made of links without a target that cannot take focus at all. Rows are now options of a listbox — Tab reaches the list, the arrow keys move between rows, Enter or Space selects — each carrying the registrant's name and type as its accessible name and its position in the whole result set, while the table around them is presentational for assistive technology so its header row is not announced as an empty table; the pager is made of real buttons whose out-of-range side is disabled rather than merely greyed; the result range and the empty-result message are mirrored into a live region; focus is kept on the pager button after a page change and handed to "Change Registrant" after a selection, instead of being dropped when the results re-render. The search box is also now labelled by its "Search Registrant" label rather than by its placeholder, and the decorative type icons are hidden from assistive technology. The inline results table looks and behaves the same for mouse users (#580) +- fix(change_request): the registrant search results in the Create Change Request wizard can be used from the keyboard and are announced by screen readers (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value). The result rows were plain table rows with a mouse click handler, so a keyboard user could type a search but never pick a result, and the Previous/Next pager was made of links without a target that cannot take focus at all. Rows are now options of a listbox — Tab reaches the list, the arrow keys move between rows, Enter or Space selects — each carrying the registrant's name and type as its accessible name and its position in the whole result set, while the table around them is presentational for assistive technology so its header row is not announced as an empty table; the pager is made of real buttons whose out-of-range side is disabled rather than merely greyed; the result range and the empty-result message are mirrored into a live region; focus is kept on the pager button after a page change and handed to "Change Registrant" after a selection, instead of being dropped when the results re-render, and is left alone when the user has moved it elsewhere while a request was in flight; the selection is confirmed through the same live region ("Selected: NAME, Individual") after the focus move so a screen reader speaks both, which replaces the alert role on the selected-registrant card (the request-type hint is a status rather than an alert for the same reason); the pager buttons are named "Previous/Next page of results" so they are not mistaken for wizard steps; the icons on the selected-registrant card are hidden from assistive technology like those in the rows; and every string the search and the card render is now translatable. The search box is also now labelled by its "Search Registrant" label rather than by its placeholder, and the decorative type icons are hidden from assistive technology. The inline results table looks and behaves the same for mouse users (#580) ### 19.0.3.1.16 diff --git a/spp_change_request_v2/static/src/js/cr_search_results_field.js b/spp_change_request_v2/static/src/js/cr_search_results_field.js index cdbebb18e..9e541186c 100644 --- a/spp_change_request_v2/static/src/js/cr_search_results_field.js +++ b/spp_change_request_v2/static/src/js/cr_search_results_field.js @@ -1,6 +1,6 @@ /** @odoo-module **/ -import {Component, onMounted, onPatched, useRef} from "@odoo/owl"; +import {Component, onMounted, onPatched, onWillUnmount, useRef} from "@odoo/owl"; import {_t} from "@web/core/l10n/translation"; import {registry} from "@web/core/registry"; import {standardFieldProps} from "@web/views/fields/standard_field_props"; @@ -9,11 +9,15 @@ const ROW_SELECTOR = ".o_cr_search_result"; const PAGER_SELECTOR = ".o_cr_page_prev, .o_cr_page_next"; const STATUS_SELECTOR = ".o_cr_search_status"; // Live region hosted by the wizard form, outside the results block, so it -// already exists when the first results arrive (see create_wizard_views.xml). +// already exists when the first results arrive and outlives a selection +// (see create_wizard_views.xml). const LIVE_REGION_SELECTOR = ".o_cr_search_live"; const CLEAR_BUTTON_SELECTOR = "button[name='action_clear_registrant']"; // Upper bound on waiting for the form to re-render after a record update. const RENDER_WAIT_MS = 2000; +// A live region filled in the same tick it is inserted is not announced, so +// the first write after mount waits a little. +const MOUNT_ANNOUNCE_DELAY_MS = 150; /** * Custom widget that renders HTML search results and handles row selection @@ -32,8 +36,9 @@ export class CrSearchResultsField extends Component { // HTML as last rendered, to tell a re-render with new results from // any other patch of the form. this.renderedHtml = null; - // Selectors to focus, in order of preference, once the results have - // been re-rendered after a page change. + // Focus to restore once the results have been re-rendered after a page + // change: the selectors to try in order, and the element that had + // focus when the page change started. this.pendingFocus = null; onMounted(() => { // One delegated listener each: the server re-renders the whole @@ -44,7 +49,8 @@ export class CrSearchResultsField extends Component { el.addEventListener("keydown", (ev) => this._onKeydown(ev)); el.addEventListener("focusin", (ev) => this._onFocusin(ev)); this.renderedHtml = this.htmlContent; - this._announceStatus(); + this.formEl = el.closest(".o_form_view"); + setTimeout(() => this._announceStatus(), MOUNT_ANNOUNCE_DELAY_MS); }); onPatched(() => { // Compared as text: the record may hand out a new Markup object @@ -56,6 +62,10 @@ export class CrSearchResultsField extends Component { this._announceStatus(); this._applyPendingFocus(); }); + // The results go away when the search is cleared or a registrant is + // chosen; the range text must not linger in the live region. A + // selection writes its own confirmation afterwards. + onWillUnmount(() => this._announce("")); } get htmlContent() { @@ -84,8 +94,11 @@ export class CrSearchResultsField extends Component { _onKeydown(ev) { // The pager buttons are native buttons: Enter and Space already click - // them. Keys with a modifier belong to the browser or to Odoo's hotkeys - // (the dialog binds Ctrl+Enter to its first footer button). + // them. Keys with a modifier belong to the browser or to Odoo's hotkeys: + // Ctrl+Enter (Cmd+Enter on macOS) is the dialog's hotkey for its first + // footer button, which is Cancel while no registrant is chosen, so it + // discards the wizard rather than selecting a row. That is standard + // Odoo dialog behaviour and is deliberately not intercepted here. const row = ev.target.closest(ROW_SELECTOR); if (!row || ev.altKey || ev.ctrlKey || ev.metaKey) { return; @@ -140,11 +153,16 @@ export class CrSearchResultsField extends Component { if (!partnerId) { return; } + const label = row.getAttribute("aria-label") || row.dataset.partnerName; + const before = document.activeElement; // Setting the registrant hides the results block, which unmounts this - // widget, so focus is handed to the button that brings the search back. - const formEl = this.containerRef.el.closest(".o_form_view"); + // widget, so focus is handed to the button that brings the search back, + // and the choice is confirmed in the live region after the focus move + // so a screen reader speaks both. + const formEl = this.formEl; await this.props.record.update({_selected_partner_id: partnerId}); - await this._focusWhenRendered(formEl, CLEAR_BUTTON_SELECTOR); + await this._focusWhenRendered(formEl, CLEAR_BUTTON_SELECTOR, before); + this._announce(_t("Selected: %s", label)); } async _goToPage(button) { @@ -161,7 +179,10 @@ export class CrSearchResultsField extends Component { : ".o_cr_page_prev"; const other = pressed === ".o_cr_page_next" ? ".o_cr_page_prev" : ".o_cr_page_next"; - this.pendingFocus = [pressed, other, ROW_SELECTOR]; + this.pendingFocus = { + selectors: [pressed, other, ROW_SELECTOR], + before: document.activeElement, + }; await this.props.record.update({_search_page: page}); // The server may answer with the same HTML (page clamped to the last // one), in which case no patch with new results ever comes. Only then @@ -176,11 +197,14 @@ export class CrSearchResultsField extends Component { /** * True when the user has put focus somewhere of their own (for example * back in the search box) while a request was in flight: focus is then - * left alone. Focus that was lost with the removed results sits on body. + * left alone. Focus that is still where it was when the action started + * (a screen reader in browse mode activates a row without moving system + * focus) or that was lost with the removed results (it sits on body) does + * not count as moved. */ - _userMovedFocus() { + _userMovedFocus(before) { const active = document.activeElement; - if (!active || active === document.body) { + if (!active || active === document.body || active === before) { return false; } const el = this.containerRef.el; @@ -188,13 +212,13 @@ export class CrSearchResultsField extends Component { } _applyPendingFocus() { - const selectors = this.pendingFocus; + const pending = this.pendingFocus; this.pendingFocus = null; const el = this.containerRef.el; - if (!selectors || !el || this._userMovedFocus()) { + if (!pending || !el || this._userMovedFocus(pending.before)) { return; } - for (const selector of selectors) { + for (const selector of pending.selectors) { const target = el.querySelector(selector); if (target && !target.disabled) { target.focus(); @@ -208,18 +232,21 @@ export class CrSearchResultsField extends Component { * because it only appears once the form has re-rendered with the onchange * result. */ - async _focusWhenRendered(root, selector) { + async _focusWhenRendered(root, selector, before) { if (!root) { return; } const deadline = Date.now() + RENDER_WAIT_MS; while (Date.now() < deadline) { - if (this._userMovedFocus()) { - return; - } const target = root.querySelector(selector); if (target) { - target.focus(); + // Decided only now that the form has re-rendered: an element + // the user moved to may have gone with the results (the search + // box is hidden once a registrant is set), in which case focus + // sits on body and is handed over after all. + if (!this._userMovedFocus(before)) { + target.focus(); + } return; } await new Promise(requestAnimationFrame); @@ -229,21 +256,32 @@ export class CrSearchResultsField extends Component { /** * Mirror the range summary ("1-10 of 23") or the empty-result message into * the form's live region, so screen readers hear the outcome of a search - * or a page change. The region is cleared first so that an unchanged text - * (a refined search with the same range) is announced again. + * or a page change. */ _announceStatus() { - const status = this.containerRef.el.querySelector(STATUS_SELECTOR); - const text = status ? status.textContent.trim() : ""; - const formEl = this.containerRef.el.closest(".o_form_view"); - const region = formEl && formEl.querySelector(LIVE_REGION_SELECTOR); + const el = this.containerRef.el; + if (!el) { + return; + } + const status = el.querySelector(STATUS_SELECTOR); + this._announce(status ? status.textContent.trim() : ""); + } + + /** + * Write into the live region, clearing it first so that an unchanged text + * (a refined search with the same range) is announced again. + */ + _announce(text) { + const region = this.formEl && this.formEl.querySelector(LIVE_REGION_SELECTOR); if (!region) { return; } region.textContent = ""; - requestAnimationFrame(() => { - region.textContent = text; - }); + if (text) { + requestAnimationFrame(() => { + region.textContent = text; + }); + } } } diff --git a/spp_change_request_v2/tests/test_create_wizard_search.py b/spp_change_request_v2/tests/test_create_wizard_search.py index d71c8ea98..1130f81da 100644 --- a/spp_change_request_v2/tests/test_create_wizard_search.py +++ b/spp_change_request_v2/tests/test_create_wizard_search.py @@ -96,6 +96,10 @@ def test_pager_renders_buttons_with_disabled_edges(self): prev = doc.xpath('.//button[contains(@class, "o_cr_page_prev")]')[0] nxt = doc.xpath('.//button[contains(@class, "o_cr_page_next")]')[0] self.assertEqual((prev.get("type"), nxt.get("type")), ("button", "button")) + # The accessible name says what the button pages through and still + # contains the visible text. + self.assertEqual(prev.get("aria-label"), "Previous page of results") + self.assertEqual(nxt.get("aria-label"), "Next page of results") self.assertIsNotNone(prev.get("disabled")) self.assertIsNone(nxt.get("disabled")) self.assertEqual((prev.get("data-page"), nxt.get("data-page")), ("-1", "1")) @@ -135,6 +139,25 @@ def test_aria_label_round_trips_quotes(self): self.assertEqual(row.get("data-partner-name"), name) self.assertEqual(row.xpath("./td")[0].text, name) + def test_search_as_cr_requestor_sees_registrants(self): + """The search runs under the acting user's record rules, so a CR requestor must get results.""" + user = self.env["res.users"].create( + {"name": "a11y_cr_requestor", "login": "a11y_cr_requestor", "email": "a11y_cr_requestor@example.com"} + ) + self.env["res.users.role.line"].create( + {"user_id": user.id, "role_id": self.env.ref("spp_change_request_v2.global_role_cr_requestor").id} + ) + user.set_groups_from_roles() + wizard = self.wizard_model.with_user(user).create({"request_type_id": self.cr_type_edit_individual.id}) + + rows = self._rows(self._render(wizard, PREFIX)) + self.assertEqual(len(rows), 10) + self.assertEqual(rows[0].get("aria-setsize"), "12") + + wizard._selected_partner_id = int(rows[0].get("data-partner-id")) + wizard._onchange_selected_partner() + self.assertEqual(wizard.registrant_id.name, rows[0].get("data-partner-name")) + def test_selected_partner_bridge_sets_registrant(self): """The integer the widget writes on Enter/click becomes the registrant.""" wizard = self._wizard() diff --git a/spp_change_request_v2/views/create_wizard_views.xml b/spp_change_request_v2/views/create_wizard_views.xml index 3075c508b..08c7efcc5 100644 --- a/spp_change_request_v2/views/create_wizard_views.xml +++ b/spp_change_request_v2/views/create_wizard_views.xml @@ -33,9 +33,11 @@ +
From dc61c928bbfc61c1ec0d110391e35939de6e72db Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 6 Oct 2026 13:55:49 +0800 Subject: [PATCH 13/15] fix(change_request): drop the pending live-region frame and mount timer when the results unmount (#580) _announce clears the live region and writes the new text a frame later, but never cancelled a frame still pending from an earlier call. When the widget unmounted between a range announcement and its frame (a selection right after a page change, or right after mount), the unmount's clear ran first and the frame then wrote the stale range back into a region that should be empty. The frame id is kept and cancelled by the next call, and the 150 ms mount timer is cleared on unmount so it cannot announce after the widget is gone. e2e test 18 now holds requestAnimationFrame callbacks across the keyboard selection (Owl keeps its own bound copy, so rendering is unaffected) and checks that releasing them does not write the range after the results are gone, then that the selection is still confirmed. Review finding by aldnav on #581. --- e2e/tests/01-spp-starter-spmis.spec.ts | 75 ++++++++++++++++++- .../static/src/js/cr_search_results_field.js | 31 ++++++-- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/e2e/tests/01-spp-starter-spmis.spec.ts b/e2e/tests/01-spp-starter-spmis.spec.ts index 765f7c3d7..85b9fec65 100644 --- a/e2e/tests/01-spp-starter-spmis.spec.ts +++ b/e2e/tests/01-spp-starter-spmis.spec.ts @@ -18,7 +18,7 @@ // 15 - Manually defines 5 more Change Request Document Type vocabulary codes for the Philippines (BIR Form 2316, Academic Calendar, Authorization Letter, Certificate of Enrolment, Valid ID of Parent), matching the e2e fixture PDFs in e2e/fixtures/, by adding rows to the same vocabulary's Codes tab as test 14 // 16 - Creates an "Edit Individual Information" change request for Santos, Jose Miguel (as admin), uploads a supporting document, and submits it for approval // 17 - Creates an "Edit Group Information" change request for Santos Family (as admin), uploads a supporting document, and submits it for approval -// 18 - Creates an "Update ID Document" change request for Santos, Jose Miguel (as admin), selecting the registrant by keyboard only, sets a National ID with tomorrow's expiry date, uploads a supporting document, and submits it for approval +// 18 - Creates an "Update ID Document" change request for Santos, Jose Miguel (as admin), selecting the registrant by keyboard only and checking that the live region does not receive a late range announcement after the results are gone, sets a National ID with tomorrow's expiry date, uploads a supporting document, and submits it for approval // 19 - Creates an HQ validator user (hqval@mail.com) with the "CR HQ Validator" role and sets its password // 20 - Logs in as the HQ validator and resolves all three pending change requests: approves // Edit Individual Information, rejects Edit Group Information (with a reason), and @@ -69,6 +69,58 @@ async function logout(page: Page) { await page.getByRole("menuitem", {name: "Log out"}).click(); } +type HeldFrames = { + pending: Map; + next: number; + request: typeof window.requestAnimationFrame; + cancel: typeof window.cancelAnimationFrame; +}; + +// Hold every requestAnimationFrame callback instead of running it. Owl keeps +// its own bound copy of the function, so rendering is unaffected; only direct +// callers such as the CR search results widget are intercepted. This pins the +// ordering "the widget unmounts before its pending frame fires", so a test can +// check what that frame would have written once the widget is gone. +async function holdAnimationFrames(page: Page) { + await page.evaluate(() => { + const held: HeldFrames = { + pending: new Map(), + next: 1, + request: window.requestAnimationFrame, + cancel: window.cancelAnimationFrame, + }; + (window as unknown as {__heldFrames: HeldFrames}).__heldFrames = held; + window.requestAnimationFrame = (cb) => { + const id = held.next++; + held.pending.set(id, cb); + return id; + }; + window.cancelAnimationFrame = (id) => { + held.pending.delete(id); + }; + }); +} + +// Restore the real functions, run whatever was held, and read the text of the +// given element right after, in the same tick, before any continuation that +// was waiting on a frame can write to it. +async function releaseAnimationFrames(page: Page, readSelector: string) { + return page.evaluate((selector) => { + const held = (window as unknown as {__heldFrames: HeldFrames}).__heldFrames; + window.requestAnimationFrame = held.request; + window.cancelAnimationFrame = held.cancel; + const callbacks = [...held.pending.values()]; + held.pending.clear(); + for (const cb of callbacks) { + cb(performance.now()); + } + return { + ran: callbacks.length, + text: document.querySelector(selector)?.textContent ?? null, + }; + }, readSelector); +} + function formatDateMDY(date: Date): string { const mm = String(date.getMonth() + 1).padStart(2, "0"); const dd = String(date.getDate()).padStart(2, "0"); @@ -1131,12 +1183,18 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { console.log("✅ Request Type: Update ID Document"); const searchBox = page.getByRole("textbox", {name: "Search Registrant"}); + // Frames are held from before the results mount until after the selection: + // the widget fills the live region one frame after clearing it, and the + // selection unmounts the widget, so the frame holding the range + // announcement must be cancelled on unmount rather than land on the + // emptied region once the results are gone (#581 review). + await holdAnimationFrames(page); await searchBox.fill("sant"); // Keyboard path (#580): the result rows are listbox options. The role // locator proves role + accessible name; Tab from the search box proves the // list is in the Tab order (fewer than 11 hits, so both pager buttons are // disabled and skipped); ArrowDown proves the keydown handler moves focus; - // Enter exercises the keydown path rather than click, and the last + // Enter exercises the keydown path rather than click, and the focus // assertion proves focus is handed to "Change Registrant" once the results // block disappears. Tests 16 and 17 keep the mouse path. const options = page.getByRole("option"); @@ -1156,7 +1214,18 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { await santosRow.focus(); await expect(santosRow).toBeFocused(); await page.keyboard.press("Enter"); - await expect(page.getByRole("button", {name: /Change Registrant/})).toBeFocused(); + const changeRegistrant = page.getByRole("button", {name: /Change Registrant/}); + await expect(changeRegistrant).toBeVisible(); + // The results are gone, so the widget has unmounted and cleared the live + // region; the frame it had queued for the range announcement must have + // gone with it, or releasing the frames writes the range back. + const liveRegion = page.locator(".o_cr_search_live").first(); + await expect(liveRegion).toHaveText(""); + const released = await releaseAnimationFrames(page, ".o_cr_search_live"); + expect(released.text, "no range announcement after the results are gone").toBe(""); + // With frames running again the focus hand-off completes and is confirmed. + await expect(changeRegistrant).toBeFocused(); + await expect(liveRegion).toHaveText("Selected: SANTOS, JOSE MIGUEL, Individual"); console.log("✅ Registrant selected by keyboard: SANTOS, JOSE MIGUEL"); await page.getByRole("button", {name: "Create"}).click(); diff --git a/spp_change_request_v2/static/src/js/cr_search_results_field.js b/spp_change_request_v2/static/src/js/cr_search_results_field.js index 9e541186c..2f5941736 100644 --- a/spp_change_request_v2/static/src/js/cr_search_results_field.js +++ b/spp_change_request_v2/static/src/js/cr_search_results_field.js @@ -40,6 +40,10 @@ export class CrSearchResultsField extends Component { // change: the selectors to try in order, and the element that had // focus when the page change started. this.pendingFocus = null; + // Deferred writes that must not outlive the widget: the frame that + // fills the live region, and the first announcement after mount. + this.announceFrame = null; + this.mountTimer = null; onMounted(() => { // One delegated listener each: the server re-renders the whole // results blob on every search or page change, so per-row handlers @@ -50,7 +54,10 @@ export class CrSearchResultsField extends Component { el.addEventListener("focusin", (ev) => this._onFocusin(ev)); this.renderedHtml = this.htmlContent; this.formEl = el.closest(".o_form_view"); - setTimeout(() => this._announceStatus(), MOUNT_ANNOUNCE_DELAY_MS); + this.mountTimer = setTimeout( + () => this._announceStatus(), + MOUNT_ANNOUNCE_DELAY_MS + ); }); onPatched(() => { // Compared as text: the record may hand out a new Markup object @@ -63,9 +70,13 @@ export class CrSearchResultsField extends Component { this._applyPendingFocus(); }); // The results go away when the search is cleared or a registrant is - // chosen; the range text must not linger in the live region. A - // selection writes its own confirmation afterwards. - onWillUnmount(() => this._announce("")); + // chosen; the range text must not linger in the live region, nor + // arrive late from the mount timer. A selection writes its own + // confirmation afterwards. + onWillUnmount(() => { + clearTimeout(this.mountTimer); + this._announce(""); + }); } get htmlContent() { @@ -269,16 +280,24 @@ export class CrSearchResultsField extends Component { /** * Write into the live region, clearing it first so that an unchanged text - * (a refined search with the same range) is announced again. + * (a refined search with the same range) is announced again. The write + * waits a frame, so a frame still pending from an earlier call is dropped: + * otherwise it would land after this call's clear, and the region would + * end up showing text that was superseded (or, on unmount, should be gone). */ _announce(text) { const region = this.formEl && this.formEl.querySelector(LIVE_REGION_SELECTOR); if (!region) { return; } + if (this.announceFrame) { + cancelAnimationFrame(this.announceFrame); + this.announceFrame = null; + } region.textContent = ""; if (text) { - requestAnimationFrame(() => { + this.announceFrame = requestAnimationFrame(() => { + this.announceFrame = null; region.textContent = text; }); } From b9ad69b1d80f2ae98f7f236b53b41a205d5d160a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 6 Oct 2026 13:55:49 +0800 Subject: [PATCH 14/15] refactor(change_request): pass the pager's disabled attribute as trusted markup (#580) The " disabled" fragment went through Markup.format() as a plain string and only survived because it holds no HTML-special characters. Wrapping it in Markup states the intent and keeps a future attribute with quotes from being escaped into broken markup. Review finding by aldnav on #581. --- spp_change_request_v2/wizards/create_wizard.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spp_change_request_v2/wizards/create_wizard.py b/spp_change_request_v2/wizards/create_wizard.py index 5554974f0..073a43f66 100644 --- a/spp_change_request_v2/wizards/create_wizard.py +++ b/spp_change_request_v2/wizards/create_wizard.py @@ -358,11 +358,11 @@ def _render_search_results(self): _("%(start)s-%(end)s of %(total)s", start=start, end=end, total=total), _("Previous page of results"), page - 1, - " disabled" if page == 0 else "", + Markup(" disabled") if page == 0 else Markup(""), _("Previous"), _("Next page of results"), page + 1, - " disabled" if page >= max_page else "", + Markup(" disabled") if page >= max_page else Markup(""), _("Next"), ) From 52612d5e9d74608acbee963fb02d47342dced6e3 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Tue, 6 Oct 2026 16:34:28 +0800 Subject: [PATCH 15/15] test(e2e): restore the real requestAnimationFrame after each test (#580) A failure between holdAnimationFrames and releaseAnimationFrames left the stub installed for the rest of the serial suite, so later tests would fail for the wrong reason. The existing afterEach now puts the real functions back, guarded so a closed or navigating page cannot mask the test's own error. Non-blocking note from aldnav's approval of #581. --- e2e/tests/01-spp-starter-spmis.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/e2e/tests/01-spp-starter-spmis.spec.ts b/e2e/tests/01-spp-starter-spmis.spec.ts index 85b9fec65..c13bd915b 100644 --- a/e2e/tests/01-spp-starter-spmis.spec.ts +++ b/e2e/tests/01-spp-starter-spmis.spec.ts @@ -187,6 +187,23 @@ test.describe.serial("OpenSPP Starter SP-MIS", () => { }); test.afterEach(async ({}, testInfo) => { + // A test that fails between holdAnimationFrames and releaseAnimationFrames + // would leave the stub in place for every later test in this serial suite; + // restore the real functions so their failures report their own cause. + // The page may be closed or mid-navigation after a failure, so a throw + // here must not mask the test's own error. + if (page) { + await page + .evaluate(() => { + const w = window as unknown as {__heldFrames?: HeldFrames}; + if (w.__heldFrames) { + window.requestAnimationFrame = w.__heldFrames.request; + window.cancelAnimationFrame = w.__heldFrames.cancel; + delete w.__heldFrames; + } + }) + .catch(() => {}); + } if (testInfo.status !== testInfo.expectedStatus && !process.env.CI) { console.log( `❌ "${testInfo.title}" failed — pausing for investigation (set CI=1 to skip)`