[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
This commit is contained in:
Bruno Boi
2021-08-03 08:51:36 +00:00
parent 67b1fdcd39
commit 3b73095302
11 changed files with 534 additions and 130 deletions
+33 -73
View File
@@ -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<DropdownState>} 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";
@@ -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;
}
}
}
@@ -3,23 +3,25 @@
<t t-name="web.Dropdown" owl="1">
<t
t-tag="props.tag or 'div'"
t-tag="rootTag"
class="o_dropdown"
t-att-class="{ 'o-dropdown--open': state.open, 'o_dropdown_item': hasParentDropdown and rootTag === 'li' }"
t-on-dropdown-item-selected="onItemSelected"
>
<button
t-if="props.toggler !== 'parent'"
t-attf-class="o_dropdown_toggler {{ props.togglerClass || '' }}"
t-att-class="{ 'o_dropdown_active': state.open }"
t-on-click="onTogglerClick"
t-on-mouseenter="onTogglerMouseEnter"
t-att-title="props.title"
t-att-data-hotkey="props.hotkey">
t-att-data-hotkey="props.hotkey"
t-ref="togglerRef"
>
<span>
<t t-slot="toggler" />
</span>
</button>
<ul t-if="state.open" class="o_dropdown_menu d-block overflow-auto" t-att-class="props.menuClass">
<ul t-if="state.open" class="o_dropdown_menu d-block" t-att-class="props.menuClass" t-ref="menuRef">
<t t-slot="default" />
</ul>
</t>
@@ -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<HTMLElement>} */
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]
);
}
@@ -87,8 +87,10 @@
&, &:focus {
background: none;
}
}
&.o_dropdown_active {
.o-dropdown{
&--open > .o_dropdown_toggler {
@extend %-main-navbar-entry-active;
}
}
@@ -0,0 +1,5 @@
.o_switch_company_menu {
.o_dropdown_menu {
min-width: 100%;
}
}
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.SwitchCompanyMenu" owl="1">
<Dropdown class="o_switch_company_menu" menuClass="'o_dropdown_menu_right'">
<Dropdown class="o_switch_company_menu">
<t t-set-slot="toggler">
<span t-attf-class="{{env.isSmall ? 'fa fa-building-o' : 'oe_topbar_name'}}">
<t t-if="!env.isSmall"><t t-esc="currentCompany.name"/></t>
@@ -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;
}
}
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="web.UserMenu" owl="1">
<Dropdown class="o_user_menu" menuClass="'o_dropdown_menu_right'" t-on-dropdown-item-selected.stop="onDropdownItemSelected">
<Dropdown class="o_user_menu" t-on-dropdown-item-selected.stop="onDropdownItemSelected">
<t t-set-slot="toggler">
<img class="rounded-circle o_user_avatar" t-att-src="source" alt="User"/> <span class="oe_topbar_name"><t t-esc="user.name"/><t t-if="env.debug" t-esc="' (' + user.db.name + ')'"/></span>
</t>
+195 -47
View File
@@ -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`
<div>
<Dropdown>
<t t-set-slot="toggler">
Outer dropdown toggler
</t>
<DropdownItem> Element A </DropdownItem>
<DropdownItem> Element B </DropdownItem>
<DropdownItem parentClosingMode="'none'">
<div class="my_custom_toggler">
Inner dropdown toggler
<Dropdown toggler="'parent'">
<DropdownItem>Element 1</DropdownItem>
<DropdownItem>Element 2</DropdownItem>
</Dropdown>
</div>
</DropdownItem>
<Dropdown class="first" hotkey="'1'" t-on-dropdown-item-selected="onItemSelected">
<DropdownItem class="first-first" payload="{val:'first-first'}" hotkey="'a'">O</DropdownItem>
<Dropdown tag="'li'" class="second" hotkey="'2'">
<DropdownItem class="second-first" payload="{val:'second-first'}" hotkey="'b'">O</DropdownItem>
<Dropdown tag="'li'" class="third" hotkey="'3'">
<DropdownItem class="third-first" payload="{val:'third-first'}" hotkey="'c'">O</DropdownItem>
<DropdownItem class="third-last" payload="{val:'third-last'}" hotkey="'d'">O</DropdownItem>
</Dropdown>
<DropdownItem class="second-last" payload="{val:'second-last'}" hotkey="'e'">O</DropdownItem>
</Dropdown>
</div>`;
<DropdownItem class="first-last" payload="{val:'first-last'}" hotkey="'f'">O</DropdownItem>
</Dropdown>
`;
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`
<Dropdown togglerClass="'first'">
<Dropdown togglerClass="'second'">
<Dropdown togglerClass="'third'"/>
</Dropdown>
</Dropdown>
`;
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");
});
});
@@ -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"
}, {