[FIX] web: dropdown unusable by screen readers

Dropdown menus/items were refactored and moved to OWL when migrating to
15.0. Now, the focus is always on the dropdown button, which captures
and handles all keyboard events, to be able to move over the menus.
Currently-selected item is highlighted by toggling classes.

However, since the focus is always on the dropdown button, screen
reader users are  no longer able to know what menu item is being
selected, as it's only a visual change (in previous versions, the focus
actually moved to the selected element).

The above is fixed by making the activeElement follow the visual change.

Part-of: odoo/odoo#106277
Co-authored-by: Luis González <lgonzalez@vauxoo.com>
This commit is contained in:
Bruno Boi
2022-11-23 13:24:51 +01:00
co-authored by Luis González
parent 279856fb49
commit 192e80c03e
5 changed files with 97 additions and 6 deletions
@@ -133,6 +133,12 @@ export class Dropdown extends Component {
}
this.toggle();
};
if (this.rootRef.el.parentElement.tabIndex === -1) {
// If the parent is not focusable, make it focusable programmatically.
// This code may look weird, but an element with a negative tabIndex is
// focusable programmatically ONLY if its tabIndex is explicitly set.
this.rootRef.el.parentElement.tabIndex = -1;
}
this.rootRef.el.parentElement.addEventListener("click", onClick);
return () => {
this.rootRef.el.parentElement.removeEventListener("click", onClick);
@@ -261,6 +267,7 @@ export class Dropdown extends Component {
*/
onTogglerMouseEnter() {
if (this.state.groupIsOpen && !this.state.open) {
this.togglerRef.el.focus();
this.open();
}
}
@@ -3,6 +3,7 @@
.dropdown-menu {
.dropdown-toggle:focus, .dropdown-item:focus {
background-color: transparent;
outline: none;
}
.dropdown-toggle.focus, .dropdown-item.focus {
background-color: $dropdown-link-hover-bg;
@@ -11,6 +11,7 @@
t-att-title="props.title"
t-att-data-hotkey="props.hotkey"
t-att="dataAttributes"
tabindex="0"
>
<t t-slot="default" />
</t>
@@ -114,6 +114,7 @@ export function useDropdownNavigation() {
}
// Make myself active
navTarget.classList.add(ACTIVE_MENU_ELEMENT_CLASS);
navTarget.focus();
};
/** @type {MenuElement} */
@@ -200,7 +201,16 @@ export function useDropdownNavigation() {
// Set up keyboard navigation ----------------------------------------------
const hotkeyService = useService("hotkey");
const closeSubDropdown = comp.parentDropdown ? comp.close : () => {};
const closeAndRefocus = () => {
const toFocus =
comp.props.toggler === "parent"
? comp.rootRef.el.parentElement
: comp.rootRef.el.querySelector(":scope > .dropdown-toggle");
comp.close().then(() => {
toFocus.focus();
});
};
const closeSubDropdown = comp.parentDropdown ? closeAndRefocus : () => {};
const openSubDropdown = () => {
const menuElement = getActiveMenuElement();
// Active menu element is a sub dropdown
@@ -229,7 +239,7 @@ export function useDropdownNavigation() {
arrowleft: localization.direction === "rtl" ? openSubDropdown : closeSubDropdown,
arrowright: localization.direction === "rtl" ? closeSubDropdown : openSubDropdown,
enter: selectActiveMenuElement,
escape: comp.close,
escape: closeAndRefocus,
};
useEffect(
(open) => {
+76 -4
View File
@@ -64,7 +64,7 @@ QUnit.module("Components", ({ beforeEach }) => {
await mount(Parent, target, { env });
assert.strictEqual(
target.querySelector(".dropdown-item").outerHTML,
'<span class="dropdown-item">coucou</span>'
'<span class="dropdown-item" tabindex="0">coucou</span>'
);
});
@@ -76,7 +76,7 @@ QUnit.module("Components", ({ beforeEach }) => {
await mount(Parent, target, { env });
assert.strictEqual(
target.querySelector(".dropdown-item").outerHTML,
'<a class="dropdown-item" href="#">coucou</a>'
'<a class="dropdown-item" tabindex="0" href="#">coucou</a>'
);
});
@@ -589,8 +589,31 @@ QUnit.module("Components", ({ beforeEach }) => {
assert.containsOnce(target, ".dropdown-menu");
});
QUnit.test("siblings dropdowns: toggler focused on mouseenter", async (assert) => {
class Parent extends Component { }
Parent.template = xml`
<div>
<Dropdown class="'one'" />
<Dropdown class="'two'" />
</div>
`;
Parent.components = { Dropdown };
env = await makeTestEnv();
await mount(Parent, target, { env });
// Click on one
target.querySelector(".one button").focus(); // mocks a real click flow
await click(target, ".one button");
assert.strictEqual(document.activeElement, target.querySelector(".one button"));
assert.containsOnce(target, ".dropdown-menu");
// Hover on two
const two = target.querySelector(".two");
two.querySelector("button").dispatchEvent(new MouseEvent("mouseenter"));
await nextTick();
assert.strictEqual(document.activeElement, two.querySelector("button"));
});
QUnit.test("dropdowns keynav", async (assert) => {
assert.expect(26);
assert.expect(41);
class Parent extends Component {
onItemSelected(value) {
assert.step(value.toString());
@@ -645,6 +668,10 @@ QUnit.module("Components", ({ beforeEach }) => {
triggerHotkey(step.hotkey);
await nextTick();
assert.hasClass(target.querySelector(".dropdown-menu > .focus"), step.expected);
assert.strictEqual(
document.activeElement,
target.querySelector(".dropdown-menu > .focus")
);
}
// Select last one activated in previous scenario (item1)
@@ -738,8 +765,39 @@ QUnit.module("Components", ({ beforeEach }) => {
assert.containsN(target, ".dropdown .dropdown-menu .dropdown-item", 2);
});
QUnit.test("props toggler='parent': refocus toggler on close with keynav", async (assert) => {
class Parent extends Component {}
Parent.template = xml`
<div>
<div class="my_custom_toggler">
Click Me
<Dropdown toggler="'parent'">
<DropdownItem>Element 1</DropdownItem>
<DropdownItem>Element 2</DropdownItem>
</Dropdown>
</div>
</div>`;
Parent.components = { Dropdown, DropdownItem };
env = await makeTestEnv();
await mount(Parent, target, { env });
assert.strictEqual(document.activeElement, document.body);
target.querySelector(".my_custom_toggler").focus(); // mocks a real click flow
await click(target, ".my_custom_toggler");
assert.strictEqual(document.activeElement, target.querySelector(".my_custom_toggler"));
triggerHotkey("ArrowDown");
await nextTick();
assert.strictEqual(
document.activeElement,
target.querySelector(".dropdown-item:first-child")
);
triggerHotkey("Escape");
await nextTick();
assert.strictEqual(document.activeElement, target.querySelector(".my_custom_toggler"));
});
QUnit.test("multi-level dropdown: keynav", async (assert) => {
assert.expect(125);
assert.expect(213);
class Parent extends Component {
onItemSelected(value) {
assert.step(value);
@@ -830,6 +888,20 @@ QUnit.module("Components", ({ beforeEach }) => {
for (const element of activeElements) {
assert.hasClass(element, step.highlighted[index++]);
}
const lastActiveElement = activeElements.slice(-1)[0];
if (lastActiveElement) {
assert.hasClass(lastActiveElement, step.highlighted.slice(-1)[0]);
assert.strictEqual(
document.activeElement,
lastActiveElement.classList.contains("dropdown")
? lastActiveElement.querySelector(":scope > .dropdown-toggle")
: lastActiveElement
);
} else {
// no active element means that the main dropdown is closed
assert.hasClass(document.activeElement, "dropdown-toggle");
assert.hasClass(document.activeElement.parentElement, "first");
}
}
if (step.selected !== undefined) {
const verify = step.selected === false ? [] : [step.selected];