commit 6dca39456e07ac627814a972bdffc6e6d8b77e23
parent 9a44e01da517c08e962c78bafd0712690381717a
Author: Drew Willcoxon <adw@mozilla.com>
Date: Fri, 5 Dec 2025 04:33:05 +0000
Bug 2000851 - Fix a11y for realtime Suggest suggestions. r=daisuke,fluent-reviewers,urlbar-reviewers,accessibility-frontend-reviewers,bolsson,flod,Jamie
This does two things:
### 1. Properly transform `OPTION` to `COMBOBOX_OPTION`
Modify `Accessible::ARIATransformRole` so that `COMBOBOX_LIST` is recognized as
a valid ancestor of `OPTION`. I'll describe the problem I'm trying to solve.
The urlbar results list consists of rows, where each row usually corresponds to
a single suggestion and a single clickable item. Recently we've added a new type
of row called "realtime" rows that can contain multiple child items. These child
items are basically individual "mini" suggestions and could be rows themselves,
but in the UI, they are all grouped side-by-side inside a single row. All items
in the row are related in some way: for example, they'll all be stock market
suggestions, or sports suggestions, or flight status suggestions.
For example, searching for "etfs" shows a realtime row that contains stock
market items, where each item corresponds to a different stock market index.
Clicking an item will do a search for that particular market index.
These individual items aren't buttons, which is why I didn't use `role="button"`
for them. As mentioned, they're more like mini suggestions.
The problem is that Firefox's a11y code doesn't recognize these items as being
options even when `role="option"` is set on them. The DOM for normal rows looks
like this:
```css
.urlbarView-row[role="presentation"] > .urlbarView-row-inner[role="option"]
```
And that works fine. For these realtime rows, the DOM looks like this:
```css
.urlbarView-row[role="presentation"] > .urlbarView-row-inner[role="group"] > .urlbarView-realtime-item[role="option"]
```
But instead of being recognized as options, the items are recognized as
"generic". And as far as I can tell, it's because of that extra layer of
hierarchy. When the extra layer is not present, the `Parent()->IsCombobox()`
if-block is entered, and the `OPTION` is transformed to `COMBOBOX_OPTION`. The
code doesn't extend that check to all ancestors -- just the parent. That seems
like an oversight.
### 2. Set `role=["group"]` and `aria-label` on the realtime row
As mentioned above, set `role=["group"]` on the `.urlbarView-row-inner`. Also
set `aria-label` on it so that screen readers inform the user about the type of
suggestion that it represents.
The realtime row is excluded from the total row count. That's unfortunate but I
don't see a way around it without radically changing the urlbar's a11y tree, for
example by using the `grid` role instead of `listbox` or something.
I tested all of this with NVDA and VoiceOver and things work as expected,
although VoiceOver doesn't seem to read the group's `aria-label`.
Differential Revision: https://phabricator.services.mozilla.com/D273368
Diffstat:
8 files changed, 119 insertions(+), 7 deletions(-)
diff --git a/accessible/basetypes/Accessible.cpp b/accessible/basetypes/Accessible.cpp
@@ -931,20 +931,21 @@ role Accessible::ARIATransformRole(role aRole) const {
}
} else if (aRole == roles::OPTION) {
- if (Parent() && Parent()->Role() == roles::COMBOBOX_LIST) {
- return roles::COMBOBOX_OPTION;
- }
-
- // Orphaned option outside the context of a listbox.
const Accessible* listbox = FindAncestorIf([](const Accessible& aAcc) {
const role accRole = aAcc.Role();
- return accRole == roles::LISTBOX ? AncestorSearchOption::Found
+ return (accRole == roles::LISTBOX || accRole == roles::COMBOBOX_LIST)
+ ? AncestorSearchOption::Found
: accRole == roles::GROUPING ? AncestorSearchOption::Continue
: AncestorSearchOption::NotFound;
});
if (!listbox) {
+ // Orphaned option outside the context of a listbox.
return NativeRole();
}
+
+ if (listbox->Role() == roles::COMBOBOX_LIST) {
+ return roles::COMBOBOX_OPTION;
+ }
} else if (aRole == roles::MENUITEM) {
// Menuitem has a submenu.
if (ARIAAttrValueIs(nsGkAtoms::aria_haspopup, nsGkAtoms::_true)) {
diff --git a/browser/components/urlbar/UrlbarView.sys.mjs b/browser/components/urlbar/UrlbarView.sys.mjs
@@ -2324,9 +2324,13 @@ export class UrlbarView {
#setRowSelectable(item, isRowSelectable) {
item.toggleAttribute("row-selectable", isRowSelectable);
item._content.toggleAttribute("selectable", isRowSelectable);
+
+ // Set or remove role="option" on the inner. "option" should be set iff the
+ // row is selectable. Some providers may set a different role if the inner
+ // is not selectable, so when removing it, only do so if it's "option".
if (isRowSelectable) {
item._content.setAttribute("role", "option");
- } else {
+ } else if (item._content.getAttribute("role") == "option") {
item._content.removeAttribute("role");
}
}
@@ -2430,6 +2434,7 @@ export class UrlbarView {
if (update.l10n) {
this.#l10nCache.setElementL10n(node, update.l10n);
} else if (update.hasOwnProperty("textContent")) {
+ this.#l10nCache.removeElementL10n(node);
lazy.UrlbarUtils.addTextContentWithHighlights(
node,
update.textContent,
diff --git a/browser/components/urlbar/content/enUS-searchFeatures.ftl b/browser/components/urlbar/content/enUS-searchFeatures.ftl
@@ -285,6 +285,10 @@ urlbar-result-yelp-realtime-business-hours-closed =
# $review_count (integer) - The review count of this.
urlbar-result-yelp-realtime-popularity = { $rating } ({ $review_count })
+# This a11y label is read by screen readers when an item in the row is selected.
+urlbar-result-aria-group-yelp-realtime =
+ .aria-label = { -yelp-brand-name } suggestions
+
## These strings are used for flight status suggestions in the urlbar.
## The flight status suggestions shows the flight time, origin and destination
## and the status like delayed, etc.
@@ -337,6 +341,10 @@ urlbar-result-flight-status-airport = { $city } ({ $code })
# $airlineName (string) - The airline name.
urlbar-result-flight-status-flight-number-with-airline = { $flightNumber }, { $airlineName }
+# This a11y label is read by screen readers when an item in the row is selected.
+urlbar-result-aria-group-flight-status =
+ .aria-label = Flight status suggestions
+
## These strings are used for sports suggestions in the urlbar. Sports
## suggestions show team names, scores, game times, etc.
@@ -368,3 +376,14 @@ urlbar-result-menu-dont-show-sports =
# A message that replaces a result when the user dismisses sports suggestions.
urlbar-result-dismissal-acknowledgment-sports = Thanks for your feedback. You won’t see sports suggestions anymore.
+
+# This a11y label is read by screen readers when an item in the row is selected.
+urlbar-result-aria-group-sports =
+ .aria-label = Sports suggestions
+
+## These strings are used for market suggestions in the urlbar.
+## TODO: Move to browser.ftl with the other market strings.
+
+# This a11y label is read by screen readers when an item in the row is selected.
+urlbar-result-aria-group-market =
+ .aria-label = Stock market suggestions
diff --git a/browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs b/browser/components/urlbar/private/RealtimeSuggestProvider.sys.mjs
@@ -123,6 +123,13 @@ export class RealtimeSuggestProvider extends SuggestProvider {
};
}
+ get ariaGroupL10n() {
+ return {
+ id: "urlbar-result-aria-group-" + this.realtimeTypeForFtl,
+ attribute: "aria-label",
+ };
+ }
+
get isSponsored() {
return false;
}
@@ -436,6 +443,7 @@ export class RealtimeSuggestProvider extends SuggestProvider {
overflowable: true,
attributes: {
selectable: hasMultipleItems ? null : "",
+ role: hasMultipleItems ? "group" : "option",
},
classList: ["urlbarView-realtime-root"],
children: items.map((item, i) => ({
@@ -444,6 +452,7 @@ export class RealtimeSuggestProvider extends SuggestProvider {
classList: ["urlbarView-realtime-item"],
attributes: {
selectable: !hasMultipleItems ? null : "",
+ role: hasMultipleItems ? "option" : "presentation",
},
children: [
// Create an image inside a container so that the image appears inset
@@ -504,6 +513,7 @@ export class RealtimeSuggestProvider extends SuggestProvider {
getViewUpdate(result) {
let { items } = result.payload;
+ let hasMultipleItems = items.length > 1;
let update = {
root: {
@@ -512,6 +522,7 @@ export class RealtimeSuggestProvider extends SuggestProvider {
url: items[0].url,
query: items[0].query,
},
+ l10n: hasMultipleItems ? this.ariaGroupL10n : null,
},
};
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_flight_status.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_flight_status.js
@@ -908,6 +908,26 @@ add_task(async function activate_multi() {
});
async function assertUI({ row, expectedList }) {
+ if (expectedList.length > 1) {
+ Assert.deepEqual(
+ document.l10n.getAttributes(row._content),
+ {
+ id: "urlbar-result-aria-group-flight-status",
+ args: null,
+ },
+ "ARIA group label should be set on the row inner"
+ );
+ } else {
+ Assert.deepEqual(
+ document.l10n.getAttributes(row._content),
+ {
+ id: null,
+ args: null,
+ },
+ "ARIA group label should not be set on the row inner"
+ );
+ }
+
let items = row.querySelectorAll(".urlbarView-realtime-item");
Assert.equal(items.length, expectedList.length);
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_market.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_market.js
@@ -122,6 +122,15 @@ add_task(async function ui_single() {
"The row should have a result menu button"
);
+ Assert.deepEqual(
+ document.l10n.getAttributes(element.row._content),
+ {
+ id: null,
+ args: null,
+ },
+ "ARIA group label should not be set on the row inner"
+ );
+
let items = element.row.querySelectorAll(".urlbarView-realtime-item");
Assert.equal(items.length, 1);
@@ -187,6 +196,15 @@ add_task(async function ui_multi() {
});
let { element } = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+ Assert.deepEqual(
+ document.l10n.getAttributes(element.row._content),
+ {
+ id: "urlbar-result-aria-group-market",
+ args: null,
+ },
+ "ARIA group label should be set on the row inner"
+ );
+
let items = element.row.querySelectorAll(".urlbarView-realtime-item");
Assert.equal(items.length, 3);
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_yelp.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_realtime_yelp.js
@@ -139,6 +139,15 @@ add_task(async function ui_single() {
Assert.equal(getComputedStyle(row, "::before").content, "attr(label)");
Assert.equal(row.getAttribute("label"), "Yelp · Sponsored");
+ Assert.deepEqual(
+ document.l10n.getAttributes(row._content),
+ {
+ id: null,
+ args: null,
+ },
+ "ARIA group label should not be set on the row inner"
+ );
+
info("Check the item");
let items = row.querySelectorAll(".urlbarView-realtime-item");
Assert.equal(items.length, 1);
@@ -206,6 +215,15 @@ add_task(async function ui_multi() {
let items = element.row.querySelectorAll(".urlbarView-realtime-item");
Assert.equal(items.length, 2);
+ Assert.deepEqual(
+ document.l10n.getAttributes(element.row._content),
+ {
+ id: "urlbar-result-aria-group-yelp-realtime",
+ args: null,
+ },
+ "ARIA group label should be set on the row inner"
+ );
+
for (let i = 0; i < items.length; i++) {
info(`Check the item[${i}]`);
let target = TEST_MERINO_MULTI[0].custom_details.yelp.values[i];
diff --git a/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_sports.js b/browser/components/urlbar/tests/quicksuggest/browser/browser_quicksuggest_sports.js
@@ -2147,6 +2147,26 @@ async function doOneTest({ expectedItems }) {
"Row should be a sports result"
);
+ if (expectedItems.length > 1) {
+ Assert.deepEqual(
+ document.l10n.getAttributes(row._content),
+ {
+ id: "urlbar-result-aria-group-sports",
+ args: null,
+ },
+ "ARIA group label should be set on the row inner"
+ );
+ } else {
+ Assert.deepEqual(
+ document.l10n.getAttributes(row._content),
+ {
+ id: null,
+ args: null,
+ },
+ "ARIA group label should not be set on the row inner"
+ );
+ }
+
// Check each realtime item in the row.
for (let i = 0; i < expectedItems.length; i++) {
let expectedItem = expectedItems[i];