diff --git a/e2e/tests/01-spp-starter-spmis.spec.ts b/e2e/tests/01-spp-starter-spmis.spec.ts index 0c946ba5f..c13bd915b 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 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"); @@ -135,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)` @@ -998,8 +1067,12 @@ 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("cell", {name: "SANTOS, JOSE MIGUEL"}).click(); + await page.getByRole("textbox", {name: "Search Registrant"}).fill("san"); + // 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(); @@ -1065,8 +1138,8 @@ 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("cell", {name: "Santos Family"}).click(); + await page.getByRole("textbox", {name: "Search Registrant"}).fill("san"); + await page.getByRole("option", {name: "Santos Family, Group", exact: true}).click(); console.log("✅ Registrant selected: Santos Family"); await page.getByRole("button", {name: "Create"}).click(); @@ -1126,11 +1199,51 @@ 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"); - await page - .locator("tr.o_cr_search_result", {hasText: "SANTOS, JOSE MIGUEL"}) - .click(); - console.log("✅ Registrant selected: SANTOS, JOSE MIGUEL"); + 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 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"); + 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(); + await page.keyboard.press("Enter"); + 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(); await page.waitForLoadState("domcontentloaded"); diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 297815919..c9ce2f591 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,40 @@ 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, 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/__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..eaac7091d 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 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 - 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. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 90a552098..c6e95c3cc 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,41 @@

Changelog

+

19.0.3.1.17

+ +
+

19.0.3.1.16

-
+

19.0.3.1.15

  • fix(change_request): refuse a date of birth in the future while the @@ -1374,7 +1409,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 +1426,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 +1443,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 +1458,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 +1497,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 +1537,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 +1554,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 +1572,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 +1587,7 @@

    19.0.3.1.7

    endpoint.
-
+

19.0.3.1.6

  • fix(security): derive conflict and duplicate detection from the change @@ -1566,7 +1601,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 +1613,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 +1630,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 +1643,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 +1652,7 @@

    19.0.3.1.2

    (#1136)
-
+

19.0.3.1.1

  • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1631,7 +1666,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 +1684,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 +1706,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 +1717,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 +1729,7 @@

    19.0.2.0.7

    dependencies.
-
+

19.0.2.0.6

  • fix(views): route post-submit CRs (pending / approved / applied / @@ -1709,7 +1744,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 +1757,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
  • 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..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 @@ -5,3 +5,26 @@ max-width: 100% !important; display: block !important; } + +/* 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(--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 024c96574..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 @@ -1,15 +1,31 @@ /** @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"; +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 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 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 +33,274 @@ export class CrSearchResultsField extends Component { setup() { this.containerRef = useRef("container"); - onMounted(() => this._attachClickHandler()); - onPatched(() => this._attachClickHandler()); + // HTML as last rendered, to tell a re-render with new results from + // any other patch of the form. + this.renderedHtml = null; + // 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; + // 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 + // would have to be re-attached after each patch. + 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.formEl = el.closest(".o_form_view"); + this.mountTimer = setTimeout( + () => this._announceStatus(), + MOUNT_ANNOUNCE_DELAY_MS + ); + }); + onPatched(() => { + // 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; + 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, nor + // arrive late from the mount timer. A selection writes its own + // confirmation afterwards. + onWillUnmount(() => { + clearTimeout(this.mountTimer); + this._announce(""); + }); } 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) => { - ev.preventDefault(); - ev.stopPropagation(); - const partnerId = parseInt(row.dataset.partnerId, 10); - if (partnerId) { - this.props.record.update({_selected_partner_id: partnerId}); - } - }; - }); - // Pagination - el.querySelectorAll(".o_cr_page_prev, .o_cr_page_next").forEach((link) => { - link.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. 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; + } + const rows = this.rows; + const index = rows.indexOf(row); + let target = null; + switch (ev.key) { + case "Enter": + case " ": ev.preventDefault(); ev.stopPropagation(); - const page = parseInt(link.dataset.page); - if (!isNaN(page) && page >= 0) { - this.props.record.update({_search_page: page}); + 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(); + 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. Handled on focusin so it also covers + * focus that arrives by mouse or from assistive technology. + */ + _onFocusin(ev) { + const row = ev.target.closest(ROW_SELECTOR); + if (!row) { + return; + } + for (const other of this.rows) { + other.tabIndex = other === row ? 0 : -1; + } + } + + async _selectRow(row) { + const partnerId = parseInt(row.dataset.partnerId, 10); + 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, + // 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, before); + this._announce(_t("Selected: %s", label)); + } + + async _goToPage(button) { + const page = parseInt(button.dataset.page, 10); + 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"; + 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 + // 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 (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 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(before) { + const active = document.activeElement; + if (!active || active === document.body || active === before) { + return false; + } + const el = this.containerRef.el; + return !(el && el.contains(active)); + } + + _applyPendingFocus() { + const pending = this.pendingFocus; + this.pendingFocus = null; + const el = this.containerRef.el; + if (!pending || !el || this._userMovedFocus(pending.before)) { + return; + } + for (const selector of pending.selectors) { + const target = el.querySelector(selector); + if (target && !target.disabled) { + target.focus(); + return; + } + } + } + + /** + * 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, selector, before) { + if (!root) { + return; + } + const deadline = Date.now() + RENDER_WAIT_MS; + while (Date.now() < deadline) { + const target = root.querySelector(selector); + if (target) { + // 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); + } + } + + /** + * 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. + */ + _announceStatus() { + 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. 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) { + this.announceFrame = requestAnimationFrame(() => { + this.announceFrame = null; + region.textContent = text; + }); + } } } 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..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 @@ -4,7 +4,9 @@ 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_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_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() + 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") + 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/views/create_wizard_views.xml b/spp_change_request_v2/views/create_wizard_views.xml index a577dae52..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 @@ +