diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index acc8ebcd3f7..e0aa6a1dc3e 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -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(); } } diff --git a/addons/web/static/src/core/dropdown/dropdown.scss b/addons/web/static/src/core/dropdown/dropdown.scss index 19f68b203c9..a6d8ecca978 100644 --- a/addons/web/static/src/core/dropdown/dropdown.scss +++ b/addons/web/static/src/core/dropdown/dropdown.scss @@ -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; diff --git a/addons/web/static/src/core/dropdown/dropdown_item.xml b/addons/web/static/src/core/dropdown/dropdown_item.xml index 38d7c332393..bc2660593af 100644 --- a/addons/web/static/src/core/dropdown/dropdown_item.xml +++ b/addons/web/static/src/core/dropdown/dropdown_item.xml @@ -11,6 +11,7 @@ t-att-title="props.title" t-att-data-hotkey="props.hotkey" t-att="dataAttributes" + tabindex="0" > diff --git a/addons/web/static/src/core/dropdown/dropdown_navigation_hook.js b/addons/web/static/src/core/dropdown/dropdown_navigation_hook.js index 154251f0118..254420a0601 100644 --- a/addons/web/static/src/core/dropdown/dropdown_navigation_hook.js +++ b/addons/web/static/src/core/dropdown/dropdown_navigation_hook.js @@ -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) => { diff --git a/addons/web/static/tests/core/dropdown_tests.js b/addons/web/static/tests/core/dropdown_tests.js index f4bc7efe4a4..8909654d3fd 100644 --- a/addons/web/static/tests/core/dropdown_tests.js +++ b/addons/web/static/tests/core/dropdown_tests.js @@ -64,7 +64,7 @@ QUnit.module("Components", ({ beforeEach }) => { await mount(Parent, target, { env }); assert.strictEqual( target.querySelector(".dropdown-item").outerHTML, - 'coucou' + 'coucou' ); }); @@ -76,7 +76,7 @@ QUnit.module("Components", ({ beforeEach }) => { await mount(Parent, target, { env }); assert.strictEqual( target.querySelector(".dropdown-item").outerHTML, - 'coucou' + 'coucou' ); }); @@ -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` +
+ + +
+ `; + 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` +
+
+ Click Me + + Element 1 + Element 2 + +
+
`; + 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];