From 3b730953021cd52ce0c3d336ab24216cef85392d Mon Sep 17 00:00:00 2001 From: Bruno Boi Date: Wed, 14 Jul 2021 11:37:21 +0000 Subject: [PATCH] [IMP] web: new dropdown design and keynav As the dropdowns have been reworked in the new webclient framework, here is an improved version of this preliminary design. Implemented specs: - nested dropdowns open themselves horizontally (at right by default) - keyboard navigation is fully adapted to this new design - up/down focus previous/next item in opened menu - tab/shift+tab focus next/previous item in opened menu - home/end focus first/last item in opened menu - right/left open/close a sub dropdown - enter selects the focused item - escape closes the current dropdown - add "loop" behaviour for keeping pressed down arrowup and arrowdown keys Taskid-2578926 --- .../web/static/src/core/dropdown/dropdown.js | 106 +++----- .../static/src/core/dropdown/dropdown.scss | 38 ++- .../web/static/src/core/dropdown/dropdown.xml | 10 +- .../core/dropdown/dropdown_navigation_hook.js | 245 ++++++++++++++++++ addons/web/static/src/legacy/scss/navbar.scss | 4 +- .../switch_company_menu.scss | 5 + .../switch_company_menu.xml | 2 +- .../src/webclient/user_menu/user_menu.scss | 6 + .../src/webclient/user_menu/user_menu.xml | 2 +- .../web/static/tests/core/dropdown_tests.js | 242 +++++++++++++---- .../static/tests/tours/main_flow.js | 4 +- 11 files changed, 534 insertions(+), 130 deletions(-) create mode 100644 addons/web/static/src/core/dropdown/dropdown_navigation_hook.js create mode 100644 addons/web/static/src/webclient/switch_company_menu/switch_company_menu.scss diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index f989a2387b4..a55318eb48e 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -1,12 +1,13 @@ /** @odoo-module **/ import { useBus, useEffect, useService } from "@web/core/utils/hooks"; -import { scrollTo } from "../utils/scrolling"; +import { usePosition } from "../position/position_hook"; +import { useDropdownNavigation } from "./dropdown_navigation_hook"; import { ParentClosingMode } from "./dropdown_item"; const { Component, core, hooks, useState, QWeb } = owl; const { EventBus } = core; -const { useExternalListener, onWillStart } = hooks; +const { onWillStart, useExternalListener, useRef, useSubEnv } = hooks; /** * @typedef DropdownState @@ -30,12 +31,14 @@ export class Dropdown extends Component { groupIsOpen: this.props.startOpen, }); + // Set up beforeOpen --------------------------------------------------- onWillStart(() => { - if ((this.state.open || this.state.groupIsOpen) && this.props.beforeOpen) { + if (this.state.open && this.props.beforeOpen) { return this.props.beforeOpen(); } }); + // Set up dynamic open/close behaviours -------------------------------- if (!this.props.manualOnly) { // Close on outside click listener useExternalListener(window, "click", this.onWindowClicked); @@ -60,76 +63,19 @@ export class Dropdown extends Component { () => [] ); + // Set up nested dropdowns --------------------------------------------- + this.hasParentDropdown = this.env.inDropdown; + useSubEnv({ inDropdown: true }); + this.rootTag = this.props.tag || (this.hasParentDropdown ? "li" : "div"); + const position = + this.props.position || (this.hasParentDropdown ? "right-start" : "bottom-start"); + // Set up key navigation ----------------------------------------------- - this.hotkeyService = useService("hotkey"); - this.hotkeyRemoves = []; - - const nextActiveIndexFns = { - FIRST: () => 0, - LAST: (items) => items.length - 1, - NEXT: (items, prevActiveIndex) => Math.min(prevActiveIndex + 1, items.length - 1), - PREV: (_, prevActiveIndex) => Math.max(0, prevActiveIndex - 1), - }; - - /** @type {(direction: "FIRST"|"LAST"|"NEXT"|"PREV") => Function} */ - function activeItemSetter(direction) { - return function () { - const items = [ - ...this.el.querySelectorAll(":scope > ul.o_dropdown_menu > .o_dropdown_item"), - ]; - const prevActiveIndex = items.findIndex((item) => - [...item.classList].includes("o_dropdown_active") - ); - const nextActiveIndex = nextActiveIndexFns[direction](items, prevActiveIndex); - items.forEach((item) => item.classList.remove("o_dropdown_active")); - items[nextActiveIndex].classList.add("o_dropdown_active"); - scrollTo(items[nextActiveIndex], this.el.querySelector(".o_dropdown_menu")); - }; - } - - const hotkeyCallbacks = { - arrowdown: activeItemSetter("NEXT").bind(this), - arrowup: activeItemSetter("PREV").bind(this), - "shift+arrowdown": activeItemSetter("LAST").bind(this), - "shift+arrowup": activeItemSetter("FIRST").bind(this), - enter: () => { - const activeItem = this.el.querySelector( - ":scope > ul.o_dropdown_menu > .o_dropdown_item.o_dropdown_active" - ); - if (activeItem) { - activeItem.click(); - } - }, - escape: this.close.bind(this), - }; - - /** @this {Dropdown} */ - function autoSubscribeKeynav() { - if (this.state.open) { - // Subscribe keynav - if (this.hotkeyRemoves.length) { - // Keynav already subscribed - return; - } - for (const [hotkey, callback] of Object.entries(hotkeyCallbacks)) { - this.hotkeyRemoves.push( - this.hotkeyService.add(hotkey, callback, { - allowRepeat: true, - }) - ); - } - } else { - // Unsubscribe keynav - for (const removeHotkey of this.hotkeyRemoves) { - removeHotkey(); - } - this.hotkeyRemoves = []; - } - } - - useEffect(autoSubscribeKeynav.bind(this)); + useDropdownNavigation(); + // Set up toggler and positioning -------------------------------------- if (this.props.toggler === "parent") { + // Add parent click listener to handle toggling useEffect( () => { const onClick = (ev) => { @@ -146,6 +92,13 @@ export class Dropdown extends Component { }, () => [] ); + + // Position menu relatively to parent element + usePosition(() => this.el.parentElement, { popper: "menuRef", position }); + } else { + // Position menu relatively to inner toggler + const togglerRef = useRef("togglerRef"); + usePosition(() => togglerRef.el, { popper: "menuRef", position }); } } @@ -164,7 +117,7 @@ export class Dropdown extends Component { * @param {Partial} stateSlice */ async changeStateAndNotify(stateSlice) { - if ((stateSlice.open || stateSlice.groupIsOpen) && this.props.beforeOpen) { + if (stateSlice.open && this.props.beforeOpen) { await this.props.beforeOpen(); } // Update the state @@ -270,10 +223,13 @@ export class Dropdown extends Component { } /** - * Opens the dropdown the mous enters its toggler. - * NB: only if its siblings dropdown group is opened. + * Opens the dropdown the mouse enters its toggler. + * NB: only if its siblings dropdown group is opened and if not a sub dropdown. */ onTogglerMouseEnter() { + if (this.hasParentDropdown) { + return; + } if (this.state.groupIsOpen && !this.state.open) { this.open(); } @@ -341,6 +297,10 @@ Dropdown.props = { type: String, optional: true, }, + position: { + type: String, + optional: true, + }, }; Dropdown.template = "web.Dropdown"; diff --git a/addons/web/static/src/core/dropdown/dropdown.scss b/addons/web/static/src/core/dropdown/dropdown.scss index 47e0b638f76..5cfaf911d60 100644 --- a/addons/web/static/src/core/dropdown/dropdown.scss +++ b/addons/web/static/src/core/dropdown/dropdown.scss @@ -69,7 +69,37 @@ @extend .dropdown-menu; &.o_dropdown_menu_right { - @extend .dropdown-menu-right; + // FIXME: does commenting this break anything? + // @extend .dropdown-menu-right; + } + + & > li.o_dropdown { + &.navigated > .o_dropdown_toggler { + // FIXME: without this there is no selection feedback + // @extend :hover; + } + & > .o_dropdown_toggler { + // FIXME: without this there is no selection feedback + // @extend .dropdown-item; + &:after { + @include o-position-absolute($right: 0); + transform: translate(-.6em, .3em); + font: .9em/1em FontAwesome; + content: "\f0da"; + } + } + + // Correctly align sub dropdowns items with its parent's + & > .o_dropdown_menu { + &.o-popper-position { + &--rs, &--ls { + margin-top: calc(-.5rem - 1px); + } + &--re, &--le { + margin-top: calc(.5rem - 2px); + } + } + } } // Since wrapping entry's content is the norm, @extend bootstrap @@ -87,6 +117,12 @@ // FIXME: does commenting this break anything? //@extend .disabled; } + &.active > :first-child { + // FIXME: without this there is no selection feedback + // @extend :hover; + background-color: rgba(0, 0, 0, 0.08); + color: #091124; + } } } diff --git a/addons/web/static/src/core/dropdown/dropdown.xml b/addons/web/static/src/core/dropdown/dropdown.xml index 636911672e3..4307327a3f7 100644 --- a/addons/web/static/src/core/dropdown/dropdown.xml +++ b/addons/web/static/src/core/dropdown/dropdown.xml @@ -3,23 +3,25 @@ -
    +
    diff --git a/addons/web/static/src/core/dropdown/dropdown_navigation_hook.js b/addons/web/static/src/core/dropdown/dropdown_navigation_hook.js new file mode 100644 index 00000000000..303478a15ba --- /dev/null +++ b/addons/web/static/src/core/dropdown/dropdown_navigation_hook.js @@ -0,0 +1,245 @@ +/** @odoo-module */ + +import { useEffect, useService } from "@web/core/utils/hooks"; +import { browser } from "../browser/browser"; +import { scrollTo } from "../utils/scrolling"; + +/** + * @typedef {{ + * el: HTMLElement, + * isActive: boolean, + * makeOnlyActive: ()=>void, + * isSubDropdown: boolean, + * isSubDropdownOpen: boolean, + * closeSubDropdown: ()=>void, + * openSubDropdown: (immediate?:boolean)=>void, + * }} MenuElement + */ + +const { hooks } = owl; +const { useComponent, useRef } = hooks; + +const ACTIVE_MENU_ELEMENT_CLASS = "active"; +const MENU_ELEMENTS_SELECTORS = [":scope > li.o_dropdown_item", ":scope > li.o_dropdown"]; +const NEXT_ACTIVE_INDEX_FNS = { + FIRST: () => 0, + LAST: (list) => list.length - 1, + NEXT: (list, prevActiveIndex) => (prevActiveIndex + 1) % list.length, + PREV: (list, prevActiveIndex) => (prevActiveIndex <= 0 ? list.length : prevActiveIndex) - 1, +}; + +export function useDropdownNavigation() { + /** @type {import("./dropdown").Dropdown} */ + const comp = useComponent(); + + // As this navigation hook relies on clicking ".o_dropdown_toggler" elements, + // it is incompatible with a toggler="parent" strategy for subdropdowns. + if (comp.hasParentDropdown && comp.props.toggler === "parent") { + throw new Error("A nested Dropdown must use its standard toggler"); + } + + // Needed to avoid unwanted selection when the mouse pointer is not in use + // but still somewhere in the middle of the dropdown menu list. + let mouseSelectionActive = true; + + // Set up menu elements logic ---------------------------------------------- + const menuRef = useRef("menuRef"); + /** @type {MenuElement[]} */ + let menuElements = []; + useEffect( + (open) => { + if (!open) { + return; + } + // Prepare MenuElements + const addedListeners = []; + /** @type {NodeListOf} */ + const queryResult = menuRef.el.querySelectorAll(MENU_ELEMENTS_SELECTORS.join()); + for (const el of queryResult) { + const isSubDropdown = el.classList.contains("o_dropdown"); + const isSubDropdownOpen = () => el.classList.contains("o-dropdown--open"); + const togglerClick = () => { + const toggler = el.querySelector(":scope > .o_dropdown_toggler"); + if (toggler) { + toggler.dispatchEvent(new MouseEvent("click", { bubbles: false })); + } + }; + let subDropdownTimeout; + const closeSubDropdown = () => { + browser.clearTimeout(subDropdownTimeout); + subDropdownTimeout = browser.setTimeout(() => { + if (isSubDropdownOpen()) { + togglerClick(); + } + }, 200); + }; + const openSubDropdown = (immediate = false) => { + browser.clearTimeout(subDropdownTimeout); + subDropdownTimeout = browser.setTimeout( + () => { + if (!isSubDropdownOpen()) { + togglerClick(); + } + }, + immediate ? 0 : 200 + ); + }; + const makeOnlyActive = () => { + // Make all others inactive + for (const menuElement of menuElements) { + if (menuElement.el === el) { + continue; + } + menuElement.el.classList.remove(ACTIVE_MENU_ELEMENT_CLASS); + if (menuElement.isSubDropdown) { + menuElement.closeSubDropdown(); + } + } + // Make myself active + el.classList.add(ACTIVE_MENU_ELEMENT_CLASS); + }; + + /** @type {MenuElement} */ + const menuElement = { + el, + get isActive() { + return el.classList.contains(ACTIVE_MENU_ELEMENT_CLASS); + }, + makeOnlyActive, + get isSubDropdownOpen() { + return isSubDropdownOpen(); + }, + isSubDropdown, + closeSubDropdown, + openSubDropdown, + }; + menuElements.push(menuElement); + + // Set up selection listeners + const elementListeners = { + click: makeOnlyActive, + mouseenter: () => { + if (!mouseSelectionActive) { + mouseSelectionActive = true; + } else { + menuElement.makeOnlyActive(); + if (isSubDropdown) { + openSubDropdown(); + } + } + }, + }; + for (const [eventType, listener] of Object.entries(elementListeners)) { + menuElement.el.addEventListener(eventType, listener); + } + addedListeners.push([menuElement, elementListeners]); + } + return () => { + menuElements = []; + mouseSelectionActive = true; + + // Clear mouse selection listeners + for (const [menuElement, listeners] of addedListeners) { + for (const [eventType, listener] of Object.entries(listeners)) { + menuElement.el.removeEventListener(eventType, listener); + } + } + }; + }, + () => [comp.state.open] + ); + + // Set up active menu element helpers -------------------------------------- + /** + * @returns {MenuElement|undefined} + */ + const getActiveMenuElement = () => { + return menuElements.find((menuElement) => menuElement.isActive); + }; + + /** + * @param {MenuElement|keyof NEXT_ACTIVE_INDEX_FNS} menuElement + */ + const setActiveMenuElement = (menuElement) => { + if (menuElements.length) { + if (typeof menuElement === "string") { + const prevIndex = menuElements.indexOf(getActiveMenuElement()); + const nextIndex = NEXT_ACTIVE_INDEX_FNS[menuElement](menuElements, prevIndex); + menuElement = menuElements[nextIndex]; + } + menuElement.makeOnlyActive(); + scrollTo(menuElement.el, menuElement.el.parentElement); + } + }; + + // Set up nested dropdowns - active first menu element behavior ------------ + useEffect( + (open) => { + // If we just opened and we are a subdropdown, make active our first menu element. + if (open && comp.hasParentDropdown) { + setActiveMenuElement("FIRST"); + } + }, + () => [comp.state.open] + ); + + // Set up keyboard navigation ---------------------------------------------- + const hotkeyService = useService("hotkey"); + let hotkeyRemoves = []; + const hotkeyCallbacks = { + home: () => setActiveMenuElement("FIRST"), + end: () => setActiveMenuElement("LAST"), + tab: () => setActiveMenuElement("NEXT"), + "shift+tab": () => setActiveMenuElement("PREV"), + arrowdown: () => setActiveMenuElement("NEXT"), + arrowup: () => setActiveMenuElement("PREV"), + arrowleft: () => { + if (comp.hasParentDropdown) { + comp.close(); + } + }, + arrowright: () => { + const menuElement = getActiveMenuElement(); + // Active menu element is a sub dropdown + if (menuElement && menuElement.isSubDropdown) { + menuElement.openSubDropdown(true); + } + }, + enter: () => { + const menuElement = getActiveMenuElement(); + if (menuElement) { + menuElement.el.click(); + } + }, + escape: comp.close, + }; + useEffect( + (open) => { + if (!open) { + return; + } + // Subscribe keynav + for (const [hotkey, callback] of Object.entries(hotkeyCallbacks)) { + const callbackWrapper = () => { + const hasOpenedSubDropdown = menuElements.some((m) => m.isSubDropdownOpen); + // Leave priority to last opened sub dropdown + if (!hasOpenedSubDropdown) { + mouseSelectionActive = false; + callback.call(comp); + } + }; + hotkeyRemoves.push( + hotkeyService.add(hotkey, callbackWrapper, { allowRepeat: true }) + ); + } + return () => { + // Unsubscribe keynav + for (const removeHotkey of hotkeyRemoves) { + removeHotkey(); + } + hotkeyRemoves = []; + }; + }, + () => [comp.state.open] + ); +} diff --git a/addons/web/static/src/legacy/scss/navbar.scss b/addons/web/static/src/legacy/scss/navbar.scss index 99eab0c8e13..6d24bfe4f96 100644 --- a/addons/web/static/src/legacy/scss/navbar.scss +++ b/addons/web/static/src/legacy/scss/navbar.scss @@ -87,8 +87,10 @@ &, &:focus { background: none; } + } - &.o_dropdown_active { + .o-dropdown{ + &--open > .o_dropdown_toggler { @extend %-main-navbar-entry-active; } } diff --git a/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.scss b/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.scss new file mode 100644 index 00000000000..79fa5b8b836 --- /dev/null +++ b/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.scss @@ -0,0 +1,5 @@ +.o_switch_company_menu { + .o_dropdown_menu { + min-width: 100%; + } +} diff --git a/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.xml b/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.xml index a7d94739e3a..69e4a7ae096 100644 --- a/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.xml +++ b/addons/web/static/src/webclient/switch_company_menu/switch_company_menu.xml @@ -2,7 +2,7 @@ - + diff --git a/addons/web/static/src/webclient/user_menu/user_menu.scss b/addons/web/static/src/webclient/user_menu/user_menu.scss index c59e6c23679..bbba28ae368 100644 --- a/addons/web/static/src/webclient/user_menu/user_menu.scss +++ b/addons/web/static/src/webclient/user_menu/user_menu.scss @@ -7,4 +7,10 @@ object-fit: cover; transform: translateY(-2px); } + + .o_dropdown_menu { + // FIXME weird hack to keep menu from taking all viewport width + min-width: 100%; + position: sticky; + } } diff --git a/addons/web/static/src/webclient/user_menu/user_menu.xml b/addons/web/static/src/webclient/user_menu/user_menu.xml index 5a6d259a0a6..f3af81f31d3 100644 --- a/addons/web/static/src/webclient/user_menu/user_menu.xml +++ b/addons/web/static/src/webclient/user_menu/user_menu.xml @@ -2,7 +2,7 @@ - + User diff --git a/addons/web/static/tests/core/dropdown_tests.js b/addons/web/static/tests/core/dropdown_tests.js index 6157ba5da78..73f7d9b7763 100644 --- a/addons/web/static/tests/core/dropdown_tests.js +++ b/addons/web/static/tests/core/dropdown_tests.js @@ -17,7 +17,7 @@ import { } from "../helpers/utils"; import { registerCleanup } from "../helpers/cleanup"; -const { mount, hooks } = owl; +const { mount } = owl; const serviceRegistry = registry.category("services"); const mainComponentsRegistry = registry.category("main_components"); @@ -310,7 +310,7 @@ QUnit.module("Components", ({ beforeEach }) => { await click(parent.el, "button.o_dropdown_toggler:last-child"); await click(parent.el, "button.o_dropdown_toggler:last-child"); assert.containsN(parent.el, "ul.o_dropdown_menu", 2); - await click(parent.el, "li"); + await click(parent.el, ".o_dropdown_item:not(.o_dropdown)"); assert.containsNone(parent.el, "ul.o_dropdown_menu"); }); @@ -373,7 +373,7 @@ QUnit.module("Components", ({ beforeEach }) => { await click(parent.el, "button.o_dropdown_toggler:last-child"); // As two listeners are defined in the template, // clicking once the item would execute the handler twice. - await click(parent.el, "li"); + await click(parent.el, ".o_dropdown_item:not(.o_dropdown)"); }); QUnit.test("multi-level dropdown: recursive template can be rendered", async (assert) => { @@ -433,7 +433,9 @@ QUnit.module("Components", ({ beforeEach }) => { env.qweb.addTemplate("recursive.Template", recursiveTemplate); parent = await mount(Parent, { env, target }); assert.deepEqual( - [...parent.el.querySelectorAll("button,li")].map((el) => el.textContent), + [...parent.el.querySelectorAll("button,.o_dropdown_item:not(.o_dropdown)")].map( + (el) => el.textContent + ), [ "foo", "foo-0", @@ -598,7 +600,7 @@ QUnit.module("Components", ({ beforeEach }) => { }); QUnit.test("dropdowns keynav", async (assert) => { - assert.expect(20); + assert.expect(26); class Parent extends owl.Component { onItemSelected(ev) { const { payload } = ev.detail; @@ -627,7 +629,7 @@ QUnit.module("Components", ({ beforeEach }) => { // Navigate with arrows assert.containsNone( parent.el, - ".o_dropdown_menu > .o_dropdown_active", + ".o_dropdown_menu > .active", "menu should not have any active items" ); @@ -635,21 +637,24 @@ QUnit.module("Components", ({ beforeEach }) => { { hotkey: "arrowdown", expected: "item1" }, { hotkey: "arrowdown", expected: "item2" }, { hotkey: "arrowdown", expected: "item3" }, - { hotkey: "arrowdown", expected: "item3" }, + { hotkey: "arrowdown", expected: "item1" }, + { hotkey: "tab", expected: "item2" }, + { hotkey: "tab", expected: "item3" }, + { hotkey: "tab", expected: "item1" }, + { hotkey: "arrowup", expected: "item3" }, { hotkey: "arrowup", expected: "item2" }, { hotkey: "arrowup", expected: "item1" }, - { hotkey: "arrowup", expected: "item1" }, - { hotkey: "shift+arrowdown", expected: "item3" }, - { hotkey: "shift+arrowup", expected: "item1" }, + { hotkey: "shift+tab", expected: "item3" }, + { hotkey: "shift+tab", expected: "item2" }, + { hotkey: "shift+tab", expected: "item1" }, + { hotkey: "end", expected: "item3" }, + { hotkey: "home", expected: "item1" }, ]; for (const step of scenarioSteps) { triggerHotkey(step.hotkey); await nextTick(); - assert.hasClass( - parent.el.querySelector(".o_dropdown_menu > .o_dropdown_active"), - step.expected - ); + assert.hasClass(parent.el.querySelector(".o_dropdown_menu > .active"), step.expected); } // Select last one activated in previous scenario (item1) @@ -708,44 +713,187 @@ QUnit.module("Components", ({ beforeEach }) => { assert.containsNone(parent, ".o_dropdown button.o_dropdown_toggler"); await click(parent.el, ".my_custom_toggler"); assert.containsOnce(parent, ".o_dropdown .o_dropdown_menu"); - assert.containsN(parent, ".o_dropdown .o_dropdown_menu li", 2); + assert.containsN(parent, ".o_dropdown .o_dropdown_menu .o_dropdown_item", 2); }); - QUnit.test("props toggler='parent' with nested dropdowns", async (assert) => { - class Parent extends owl.Component {} + QUnit.test("multi-level dropdown: keynav", async (assert) => { + assert.expect(119); + class Parent extends owl.Component { + onItemSelected(ev) { + const { payload } = ev.detail; + assert.step(payload.val); + } + } Parent.template = owl.tags.xml` -
    - - - Outer dropdown toggler - - Element A - Element B - -
    - Inner dropdown toggler - - Element 1 - Element 2 - -
    -
    + + O + + O + + O + O + + O -
    `; - + O +
    + `; env = await makeTestEnv(); parent = await mount(Parent, { env, target }); - assert.containsOnce(parent, ".o_dropdown"); - assert.containsOnce(parent, ".o_dropdown button.o_dropdown_toggler"); - assert.containsNone(parent, ".o_dropdown .o_dropdown_menu"); - await click(parent.el, ".o_dropdown_toggler"); - assert.containsOnce(parent, ".o_dropdown .o_dropdown_menu"); - assert.containsN(parent, ".o_dropdown .o_dropdown_menu li", 3); - assert.containsOnce(parent, ".o_dropdown .o_dropdown"); - assert.containsNone(parent, ".o_dropdown .o_dropdown .o_dropdown_menu"); - assert.containsNone(parent, ".o_dropdown .o_dropdown button.o_dropdown_toggler"); - await click(parent.el, ".o_dropdown .my_custom_toggler"); - assert.containsOnce(parent, ".o_dropdown .o_dropdown .o_dropdown_menu"); - assert.containsN(parent, ".o_dropdown .o_dropdown .o_dropdown_menu li", 2); + assert.containsNone(parent.el, ".o_dropdown_menu", "menus are closed at start"); + + // Open through hotkeys + triggerHotkey("alt+2"); + await nextTick(); + assert.containsNone(parent.el, ".o_dropdown_menu", "no menu is opened"); + + triggerHotkey("alt+1"); + await nextTick(); + assert.containsOnce(parent.el, ".o_dropdown_menu", "first menu is opened"); + + triggerHotkey("alt+2"); + await nextTick(); + assert.containsN(parent.el, ".o_dropdown_menu", 2, "second menu is opened"); + + triggerHotkey("alt+3"); + await nextTick(); + assert.containsN(parent.el, ".o_dropdown_menu", 3, "both menus are opened"); + + // Close through hotkeys + triggerHotkey("escape"); + await nextTick(); + assert.containsN(parent.el, ".o_dropdown_menu", 2, "third menu is closed"); + + triggerHotkey("escape"); + await nextTick(); + assert.containsOnce(parent.el, ".o_dropdown_menu", "second menu is closed"); + + triggerHotkey("escape"); + await nextTick(); + assert.containsNone(parent.el, ".o_dropdown_menu", "both menus are closed"); + + // Highlighting and selecting items + const scenarioSteps = [ + { hotkey: "alt+1" }, + { hotkey: "arrowup", highlighted: ["first-last"] }, + { hotkey: "arrowup", highlighted: ["second"] }, + { hotkey: "arrowdown", highlighted: ["first-last"] }, + { hotkey: "arrowdown", highlighted: ["first-first"] }, + { hotkey: "arrowdown", highlighted: ["second"] }, + { hotkey: "tab", highlighted: ["first-last"] }, + { hotkey: "tab", highlighted: ["first-first"] }, + { hotkey: "tab", highlighted: ["second"] }, + { hotkey: "shift+tab", highlighted: ["first-first"] }, + { hotkey: "shift+tab", highlighted: ["first-last"] }, + { hotkey: "shift+tab", highlighted: ["second"] }, + { hotkey: "arrowright", highlighted: ["second", "second-first"] }, + { hotkey: "arrowright", highlighted: ["second", "second-first"] }, + { hotkey: "arrowleft", highlighted: ["second"] }, + { hotkey: "arrowleft", highlighted: ["second"] }, + { hotkey: "arrowright", highlighted: ["second", "second-first"] }, + { hotkey: "arrowup", highlighted: ["second", "second-last"] }, + { hotkey: "arrowup", highlighted: ["second", "third"] }, + { hotkey: "arrowup", highlighted: ["second", "second-first"] }, + { hotkey: "arrowdown", highlighted: ["second", "third"] }, + { hotkey: "arrowright", highlighted: ["second", "third", "third-first"] }, + { hotkey: "arrowright", highlighted: ["second", "third", "third-first"] }, + { hotkey: "arrowleft", highlighted: ["second", "third"] }, + { hotkey: "arrowleft", highlighted: ["second"] }, + { hotkey: "arrowleft", highlighted: ["second"] }, + { hotkey: "arrowright", highlighted: ["second", "second-first"] }, + { hotkey: "arrowdown", highlighted: ["second", "third"] }, + { hotkey: "arrowright", highlighted: ["second", "third", "third-first"] }, + { hotkey: "arrowup", highlighted: ["second", "third", "third-last"] }, + { hotkey: "home", highlighted: ["second", "third", "third-first"] }, + { hotkey: "home", highlighted: ["second", "third", "third-first"] }, + { hotkey: "end", highlighted: ["second", "third", "third-last"] }, + { hotkey: "end", highlighted: ["second", "third", "third-last"] }, + { hotkey: "enter", selected: "third-last" }, + { hotkey: "alt+1" }, + { hotkey: "alt+e", selected: false }, + { hotkey: "alt+f", selected: "first-last" }, + { hotkey: "alt+1" }, + { hotkey: "alt+2", highlighted: ["second", "second-first"] }, + { hotkey: "alt+3", highlighted: ["second", "third", "third-first"] }, + { hotkey: "alt+d", selected: "third-last" }, + { hotkey: "alt+1" }, + { hotkey: "alt+2", highlighted: ["second", "second-first"] }, + { hotkey: "alt+e", selected: "second-last" }, + ]; + + for (const [stepIndex, step] of scenarioSteps.entries()) { + triggerHotkey(step.hotkey); + await nextTick(); + if (step.highlighted !== undefined) { + let index = 0; + const activeElements = parent.el.querySelectorAll(".active"); + assert.ok( + activeElements.length === step.highlighted.length, + `step ${stepIndex}: all active elements to check are found` + ); + for (const element of activeElements) { + assert.hasClass(element, step.highlighted[index++]); + } + } + if (step.selected !== undefined) { + const verify = step.selected === false ? [] : [step.selected]; + assert.verifySteps(verify, `step ${stepIndex}: selected item is correct`); + } + } + }); + + QUnit.test("multi-level dropdown: unsubscribe all keynav when root close", async (assert) => { + assert.expect(14); + class Parent extends owl.Component {} + Parent.template = owl.tags.xml` + + + + + + `; + env = await makeTestEnv(); + let hotkeyRegistrationsCount = 0; + patchWithCleanup(env.services.hotkey, { + add() { + const remove = this._super(...arguments); + hotkeyRegistrationsCount += 1; + return () => { + remove(); + hotkeyRegistrationsCount -= 1; + }; + }, + }); + parent = await mount(Parent, { env, target }); + assert.containsNone(parent.el, ".o_dropdown_menu", "menus are closed at start"); + assert.strictEqual(hotkeyRegistrationsCount, 0, "no hotkey registered"); + + // Open dropdowns one by one + await click(parent.el, ".first"); + assert.containsOnce(parent.el, ".o_dropdown_menu", "1st menu is opened"); + assert.strictEqual(hotkeyRegistrationsCount, 10, "1st menu hotkeys registered"); + + await click(parent.el, ".second"); + assert.containsN(parent.el, ".o_dropdown_menu", 2, "2nd menu is also opened"); + assert.strictEqual(hotkeyRegistrationsCount, 20, "2nd menu hotkeys also registered"); + + await click(parent.el, ".third"); + assert.containsN(parent.el, ".o_dropdown_menu", 3, "3rd menu is also opened"); + assert.strictEqual(hotkeyRegistrationsCount, 30, "3rd menu hotkeys also registered"); + + // Close second + await click(parent.el, ".second"); + assert.containsN(parent.el, ".o_dropdown_menu", 1, "only 1st menu stay opened"); + assert.strictEqual(hotkeyRegistrationsCount, 10, "only 1st menu hotkeys registered"); + + // Reopen second + await click(parent.el, ".second"); + assert.containsN(parent.el, ".o_dropdown_menu", 2, "2nd menu is also opened"); + assert.strictEqual(hotkeyRegistrationsCount, 20, "2nd menu hotkeys also registered"); + + // Close first + await click(parent.el, ".first"); + assert.containsNone(parent.el, ".o_dropdown_menu", "all menus are now closed"); + assert.strictEqual(hotkeyRegistrationsCount, 0, "no hotkey registration left"); }); }); diff --git a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js index e8549b3cbcd..6e5c77b9c1f 100644 --- a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js +++ b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js @@ -677,7 +677,7 @@ tour.stepUtils.openBuggerMenu("li.breadcrumb-item.active:contains('Inventory Ove position: "bottom" }, { mobile: true, - trigger: ".o_menu_sections li:contains('Reordering Rules')", + trigger: ".o_menu_sections li.o_dropdown_item:not(.o_dropdown):contains('Reordering Rules')", content: _t("Click on Product Section"), position: "bottom" }, { @@ -822,7 +822,7 @@ tour.stepUtils.openBuggerMenu("li.breadcrumb-item.active:contains('Manufacturing position: "bottom", }, { mobile: true, - trigger: ".o_menu_sections li:contains('Manufacturing Orders')", + trigger: ".o_menu_sections li.o_dropdown_item:contains('Manufacturing Orders')", content: _t("Let's go to manufacturing order."), position: "bottom" }, {