[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:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}, {
|
||||
|
||||
Reference in New Issue
Block a user