Skip to content

fix(spp_change_request_v2): keyboard-accessible search results in the CR create wizard (#580) - #581

Merged
gonzalesedwin1123 merged 15 commits into
19.0from
fix-580-cr-search-a11y
Oct 6, 2026
Merged

gonzalesedwin1123 merged 15 commits into
19.0from
fix-580-cr-search-a11y

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Oct 2, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #580.

The Create Change Request wizard renders its registrant search results server-side as plain <tr>s with a mouse onclick, and a Previous/Next pager made of <a> tags without href. Nothing in it can take keyboard focus, so a keyboard or screen-reader user can type a search but never pick a result (WCAG 2.1.1 Keyboard, 4.1.2 Name/Role/Value, both Level A).

This keeps the inline results table exactly as designed (see the discussion on #580 — the previous Many2one picker is not coming back) and makes it conform:

  • Rows are options of a listbox: role="option", roving tabindex (one Tab stop for the list), aria-label = "NAME, Individual|Group". Enter/Space select, ArrowUp/ArrowDown/Home/End move between rows.
  • Pager is made of real <button type="button">s; the out-of-range side carries a disabled attribute instead of muted styling, so it is skipped in the Tab order and cannot be clicked.
  • Focus is never dropped. Every search/page change re-renders the whole t-out blob (destroying the focused node), so focus is put back on the pager button that was pressed once the form has re-rendered. Selecting a row hides the results block and unmounts the widget, so focus is handed to Change Registrant.
  • Live region: a visually-hidden role="status" node outside the re-rendered blob announces the result range ("1-10 of 23") and "No registrants found."
  • The debounced search input now carries id="props.id", so its "Search Registrant" <label> names it (previously the accessible name fell back to the placeholder). Type icons get aria-hidden="true". A :focus-visible ring is added for rows because the table-hover shading swallows the default one.
  • Click handling moves from per-row onclick re-attached after every patch to one delegated listener, which is what lets the keyboard path share the click path.

No model, view-structure, ACL or UX change. Mouse users see the same table; the only visible difference is that Previous/Next are now buttons styled as links.

README.rst / static/description/index.html are deliberately not regenerated locally; CI's pinned generator prints the expected diff, which will be applied verbatim in a follow-up commit.

Test plan

  • ./spp t spp_change_request_v2: 0 failed of 427 (420 baseline + 7 new in tests/test_create_wizard_search.py). The 5 rendering tests fail on origin/19.0 and pass here; the bridge/paging tests guard behaviour the widget relies on that had no coverage.
  • e2e test 18 now selects the registrant by keyboard only: getByRole("option", {name: "SANTOS, JOSE MIGUEL, Individual"}) → focus() → toBeFocused() → Enter → Change Registrant is focused. Tests 16/17 keep the mouse path. (Runs in the E2E workflow.)
  • Manual: Change Requests → New Request → pick a type → type a name → Tab into the list → arrows, Enter; Previous/Next by keyboard on a search with >10 hits; screen reader announces "x-y of N".

Review round (code + UX/a11y reviewers)

  • Table/listbox clash: <table role="presentation">, <thead aria-hidden="true"> — otherwise the header row is a table with no data cells next to the listbox.
  • First-search announcement: the live region now lives in the wizard form (.o_cr_search_live), present before results arrive; a region inserted together with its text is not announced. It is cleared before each write so an unchanged range is announced again.
  • Slow-link focus race: the 500 ms timer started before the RPC; on a slow link it focused the old pager button right before the re-render removed it. Focus is now applied in onPatched when the HTML actually changed, with a guarded fallback for an unchanged answer.
  • Hotkeys: modifier keys are left alone (Ctrl+Enter is the dialog's footer-button hotkey) and handled keys stop propagating. Roving tabindex also follows focusin.
  • aria-setsize/aria-posinset (position within the whole result set), listbox named "Registrant search results", pager arrows aria-hidden, autocomplete="off" on the search box.
  • Focus ring was not painting at all: --bs-primary does not exist in Odoo 19's backend (Bootstrap is compiled without the prefix), so the declaration was invalid. Now var(--primary, #714b67), with a <td> box-shadow fallback (outlines on <tr> are unreliable under border-collapse) and the same ring on the pager buttons. Verified computed: outline: solid 2px rgb(93, 141, 168).
  • e2e: the label association changed the search box's accessible name, so the three locators using the placeholder were repointed at "Search Registrant" (tests 16-18 would otherwise fail on the next push to 19.0 — the E2E workflow does not run on PRs). Test 18 also proves Tab reaches the list and ArrowDown/Home move focus. The E2E workflow has been dispatched on this branch.

Smoke run against a local MIS demo stack (787 hits for "an"): 15/15 keyboard checks pass — label name, live region before first search, Tab → Next → first row, ArrowDown/Home/End, roving tabindex, Next/Previous by keyboard with focus kept on the pager and "11-20 of 787" announced, Enter → "Change Registrant" focused, mouse click unchanged, "No registrants found." announced.

Not done, flagged for the maintainers: there is no JS unit-test runner in this repo (only tours), so the widget's keyboard logic is covered by the Python rendering tests and the Playwright spec rather than hoot tests. Pre-existing, out of scope: untranslated strings in the renderer; focus after "Change Registrant" reopens the dialog; no "Searching…" status while the RPC is in flight.

E2E workflow (dispatched on this branch, since it does not run on PRs)

  • Run 36987422985 (before the last commit): spec 02 green, spec 01 hung in test 16 at getByRole("cell", {name: "SANTOS, JOSE MIGUEL"}) — a cell inside a tr role="option" is presentational to the accessibility tree, so the locator never resolved. Tests 16/17 now click the row by its option name like test 18.
  • Run 36989512418 (current head): both specs green.

Full-flow UI check on a local MIS demo stack (visible Chromium): 25/25 — Edit Individual by keyboard end to end incl. Create opening the detail form, Edit Group by mouse incl. pager, type switching clears the search, exact ID-number search (partial does not match), 1-char and no-hit searches, Create New Group hides the picker; zero console/page errors, zero Odoo error dialogs.

Third check round

  • Focus is never stolen. If the user presses Next and goes back to typing before the response arrives, or moves to another field while a slow selection is in flight, focus stays where they put it; restore/hand-off only happens when focus was lost with the removed results. Verified with 1.5 s emulated latency (CDP) in both directions: focus survives on Next after the page renders, and is not taken from the search box.
  • Results HTML compared as text (not Markup identity) when deciding whether a patch carried new results.
  • Test: quotes and angle brackets in a name round-trip through aria-label, data-partner-name and the cell.
  • Probes on the live stack (15/15): aria snapshot is status + button "Previous"/"Next" + listbox "Registrant search results" > option "NAME, Individual" with no table/rowgroup/cell nodes; Tab order search → Next → one row → Cancel and back with Shift+Tab; roving tabindex follows arrow keys and programmatic focus; Space selects without scrolling; Ctrl+Enter on a row does not select; the live region is cleared then refilled so an equal range is re-announced.

Fourth round (second reviewer pass, verified empirically on the live stack)

  • Browse-mode activation and in-flight focus moves: whether the user "moved focus" is now decided only after the form has re-rendered, against the element focused when the action started. A screen reader activating a row without moving system focus, or a user clicking into the search box while a slow selection is in flight (the box disappears with the results), both end with focus on "Change Registrant" rather than body. Probed with a dispatched click while focus sat in the search box, and with a focus move during 1.5 s latency.
  • Selection confirmation: the selected-registrant card lost its role="alert" (an alert inserted in the same frame as a focus move is cut off); the choice is confirmed through the live region ("Selected: NAME, Individual") after focus settles, and the region now survives a selection. The request-type hint is role="status" so it no longer interrupts on every type change; the region is cleared when the results go away.
  • Pager names "Previous/Next page of results"; card icons aria-hidden; every string the search and the card render wrapped in _().
  • Tests: a CR requestor (role → record rules in force) gets the same results and can select; pager names. Suite 0/431.
  • Probes 18/18, smoke 16/16, full-flow regression 25/25 on the final widget.
  • Confirmed from Odoo 19 source by the reviewer: Record.update() resolves after the onchange is applied; html values are Markup objects and the same instance is kept for unchanged values, so the text comparison is a safety net, not a requirement.

…le 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 <a> 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.
…ion 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 <body>.

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.
#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.
README.rst and static/description/index.html are left for CI's pinned
generator, whose output is applied verbatim in a follow-up commit.
@codecov

codecov Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.12%. Comparing base (612b99b) to head (52612d5).
⚠️ Report is 55 commits behind head on 19.0.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #581      +/-   ##
==========================================
+ Coverage   77.04%   77.12%   +0.08%     
==========================================
  Files         727      727              
  Lines       47165    47157       -8     
==========================================
+ Hits        36338    36371      +33     
+ Misses      10827    10786      -41     
Flag Coverage Δ
spp_api_v2_change_request 73.37% <ø> (ø)
spp_base_common 91.07% <ø> (ø)
spp_change_request_v2 79.89% <100.00%> (+0.99%) ⬆️
spp_cr_type_assign_program 92.50% <ø> (ø)
spp_dci_demo 94.28% <ø> (ø)
spp_farmer_registry_cr 61.24% <ø> (ø)
spp_farmer_registry_demo 63.39% <ø> (ø)
spp_mis_demo_v2 70.41% <ø> (ø)
spp_programs 67.58% <ø> (+0.02%) ⬆️
spp_registry 89.00% <ø> (ø)
spp_security 69.56% <ø> (ø)
spp_starter_sp_mis 86.84% <ø> (-2.05%) ⬇️
spp_studio_change_requests 84.85% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_change_request_v2/wizards/create_wizard.py 76.92% <100.00%> (+21.18%) ⬆️

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…e pager arrows for the search results (#580)

Review round. A listbox inside an exposed <table> left the header row as
a table with no data cells, so the table is now role="presentation" and
the <thead> 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.
… 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
<tr> are unreliable under border-collapse, and the pager buttons get the
same ring. The search box also gets autocomplete="off".
…gation 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.
#580)

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.
…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.
…rowse-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.

@aldnav aldnav left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR @gonzalesedwin1123

I posted reviews on all changed files. The ARIA semantics, XSS handling, event delegation, and focus management are all correct and well-tested. Three items below — one medium, two low. The medium one (stale requestAnimationFrame) is the only correctness issue; the other two are hardening.

if (text) {
requestAnimationFrame(() => {
region.textContent = text;
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Stale requestAnimationFrame — race between clear and write

If _announce("1-10 of 23") is called (e.g. from onPatched → _announceStatus) and then _announce("") runs before the first rAF fires (e.g. from onWillUnmount), the clear in the second call happens synchronously but the pending rAF from the first call still executes and writes stale text back into the live region.

The consequence is that the live region shows "1-10 of 23" after the widget has unmounted, when it should be empty.

Suggested fix — track and cancel the pending rAF:

_announce(text) {
    const region = this.formEl && this.formEl.querySelector(LIVE_REGION_SELECTOR);
    if (!region) {
        return;
    }
    if (this._announceRaf) {
        cancelAnimationFrame(this._announceRaf);
    }
    region.textContent = "";
    if (text) {
        this._announceRaf = requestAnimationFrame(() => {
            region.textContent = text;
            this._announceRaf = null;
        });
    }
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in dc61c92. _announce now keeps the frame id and cancels a pending frame before clearing, and onWillUnmount clears the mount timer as well.

Reproduced first with a browser probe that holds requestAnimationFrame callbacks instead of running them (Owl keeps its own bound copy of the function, so rendering is unaffected). Before the fix, flushing the held frame after a selection wrote "11-20 of 787" back into the emptied live region (cancelled 0); after the fix the region stays empty (cancelled 1) and the "Selected: …" confirmation still follows. The same check now lives in e2e test 18, around the keyboard selection.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Uncanceled setTimeout on mount

If the component unmounts within 150 ms (e.g. the user picks a pre-filled registrant immediately), this timer fires after onWillUnmount. The _announceStatus method guards against a null containerRef.el so it is a no-op, but it is still a dangling timer referencing a destroyed component.

Suggested fix — store the timer ID and clear it in onWillUnmount:

// in onMounted:
this._mountTimer = setTimeout(
    () => this._announceStatus(), MOUNT_ANNOUNCE_DELAY_MS
);

// in onWillUnmount:
onWillUnmount(() => {
    clearTimeout(this._mountTimer);
    this._announce("");
});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in dc61c92: the timer id is stored on mount and cleared in onWillUnmount. The probe for the frame race also exercises this path (a selection right after mount): the only frame still held afterwards is the focus helper's, so the timer no longer announces once the widget is gone.

_("%(start)s-%(end)s of %(total)s", start=start, end=end, total=total),
_("Previous page of results"),
page - 1,
" disabled" if page == 0 else "",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] disabled attribute injected as a plain string into Markup.format()

This works today because " disabled" contains no HTML-special characters, so Markup.format()'s auto-escaping passes it through unchanged. But it is fragile: if someone later changes this to a value attribute (e.g. ' disabled="disabled"'), the quotes would be escaped and produce broken markup.

The more idiomatic pattern is to mark the fragment as trusted:

Markup(" disabled") if page == 0 else Markup(""),

(Same applies to the " disabled" on line 365.)

Non-blocking — just a hardening suggestion for a follow-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken in this PR rather than a follow-up, since it is still open: both fragments are Markup(" disabled") / Markup("") in b9ad69b. The rendered output is unchanged and test_pager_renders_buttons_with_disabled_edges still passes.

…er 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.
…ted 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.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Review round applied (aldnav, 2026-10-06), head b9ad69b:

  • dc61c92 — _announce cancels a pending frame before clearing, and unmount clears the mount timer; e2e test 18 now holds animation frames across the keyboard selection and asserts no range text lands after the results are gone.
  • b9ad69b — the pager's disabled fragment is passed as Markup.

Verification: spp_change_request_v2 431/0/0 locally; the 18 browser probes from the original round still pass; a new 6-probe race script (held frames) fails on 1a4a831 and passes on dc61c92. Lint clean on the changed files. The e2e change is verified by CI.

@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

@aldnav can you check again before I merge this.

@aldnav aldnav left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three findings from the first round are addressed. The cancelAnimationFrame fix, the clearTimeout cleanup, and the Markup(" disabled") hardening are all correct. The new e2e regression test for the rAF race is well-designed — OWL's scheduler uses its own bound copy of requestAnimationFrame (captured at module load), so the held-frames helper only intercepts the widget's calls, and the test precisely proves the stale-announcement race is gone.

One non-blocking note: if the e2e test fails between holdAnimationFrames() and releaseAnimationFrames(), window.requestAnimationFrame stays permanently replaced for subsequent serial tests. A defensive afterEach cleanup would make failure diagnostics cleaner:

test.afterEach(async ({page}) => {
    await page.evaluate(() => {
        const held = (window as any).__heldFrames;
        if (held) {
            window.requestAnimationFrame = held.request;
            window.cancelAnimationFrame = held.cancel;
            delete (window as any).__heldFrames;
        }
    });
});

Not blocking — the serial tests are state-dependent and would cascade on failure anyway.

Thanks a lot @gonzalesedwin1123

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.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Thanks @aldnav. Took the non-blocking note as well, in 52612d5: the suite's existing afterEach now restores the real requestAnimationFrame / cancelAnimationFrame if a test left the stub installed. It uses the suite's shared page (the spec does not use the page fixture), with a null guard and a swallowed evaluate error so a closed or navigating page cannot mask the failing test's own error. E2E Tests dispatched on this head: run 37437028748.

@aldnav aldnav left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gonzalesedwin1123 and OpenSPP Team!

@gonzalesedwin1123
gonzalesedwin1123 merged commit 6db1d94 into 19.0 Oct 6, 2026
37 checks passed
@gonzalesedwin1123
gonzalesedwin1123 deleted the fix-580-cr-search-a11y branch October 6, 2026 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CrSearchResultsField: keyboard-inaccessible search results (WCAG 2.1.1, 4.1.2)

2 participants