[FIX] web: open menus in new tab

Before this commit, menus in navbar and clickable elements on the home
menu were not openable in a new tab through a ctrl-click or through the
middlemouse button.

In order to achieve that, we now use default behavior of <a href/> elements.

As such default behaviour would conflict with <DropdownItem/> clicks,
a global click handler is added in the WebClient constructor which stops
the click event propagation under these specific circumstances
(ctrl-click inside an <a href/> element).

Besides this work, it has been found that the <Dropdown/> root element
should not always be a <div/> as it was, but sometimes it has to be
another HTML element in order to comply with the W3C specs. E.g. a
<Dropdown/> component as a first level child of an <ul/> element must
have a <li/> root element instead of a <div/> one.

Thus, a new prop is added to the <Dropdown/> component (`tag`) allowing
the developer to choose another root element: `<Dropdown tag="'li'"/>`.

closes odoo-dev/odoo#913

Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
Bruno Boi
2021-06-18 21:31:33 +02:00
committed by Aaron Bohy
parent b124343d21
commit 47acba560a
15 changed files with 198 additions and 55 deletions
@@ -69,12 +69,6 @@ export class DebugMenu extends Component {
ev.detail.payload.callback();
}
}
onClickOnTagA(ev) {
if (!ev.ctrlKey) {
ev.preventDefault();
}
}
}
DebugMenu.template = "web.DebugMenu";
@@ -12,8 +12,8 @@
<t t-if="!element.hide">
<t t-if="element.type == 'item'">
<DropdownItem payload="{ callback: element.callback }">
<a t-if="element.href" t-att-href="element.href" t-esc="element.description" t-on-click="onClickOnTagA"/>
<t t-else="" t-esc="element.description"/>
<a t-if="element.href" t-att-href="element.href" t-esc="element.description"/>
<span t-else="" t-esc="element.description"/>
</DropdownItem>
</t>
<div t-if="element.type == 'separator'" role="separator" class="dropdown-divider"/>
@@ -302,6 +302,10 @@ Dropdown.props = {
type: Function,
optional: true,
},
tag: {
type: String,
optional: true,
},
togglerClass: {
type: String,
optional: true,
@@ -26,6 +26,9 @@
@extend .dropdown-item.active;
}
}
.dropdown-menu .dropdown-item {
// Using <a> tags inside a '.dropdown-item' elements should not be necessary
// since the class in usually applied to the <a> tag itself.
// The following rules will handle custom layouts needs (eg '.o_user_menu').
@@ -80,6 +83,14 @@
&_item {
@extend .dropdown-item;
// we want the dropdownitem first child to define its own padding
// because it can be an <a href/>
padding: 0;
> *:first-child {
padding: $dropdown-item-padding-y $dropdown-item-padding-x;
display: flex;
}
&.o_dropdown_active {
@extend .dropdown-item:hover;
}
@@ -2,7 +2,11 @@
<templates xml:space="preserve">
<t t-name="web.Dropdown" owl="1">
<div class="o_dropdown" t-on-dropdown-item-selected="onItemSelected">
<t
t-tag="props.tag or 'div'"
class="o_dropdown"
t-on-dropdown-item-selected="onItemSelected"
>
<button
t-attf-class="o_dropdown_toggler {{ props.togglerClass || '' }}"
t-att-class="{ 'o_dropdown_active': state.open }"
@@ -17,7 +21,7 @@
<ul t-if="state.open" class="o_dropdown_menu d-block overflow-auto" t-att-class="props.menuClass">
<t t-slot="menu" />
</ul>
</div>
</t>
</t>
</templates>
@@ -2,10 +2,13 @@
<templates xml:space="preserve">
<t t-name="web.DropdownItem" owl="1">
<li class="o_dropdown_item" t-on-click="onClick" t-att-title="props.title" t-att-data-hotkey="props.hotkey">
<span>
<t t-slot="default" />
</span>
<li
class="o_dropdown_item"
t-on-click="onClick"
t-att-title="props.title"
t-att-data-hotkey="props.hotkey"
>
<t t-slot="default" />
</li>
</t>
+10 -6
View File
@@ -93,6 +93,12 @@
}
}
.o_nav_entry {
padding: 0;
@extend %-main-navbar-entry-base;
@extend %-main-navbar-entry-padding;
}
.o_dropdown .o_dropdown_menu, .dropdown-menu {
margin-top: 0;
border-top: 0;
@@ -100,7 +106,9 @@
}
.o_dropdown_menu_group_entry {
padding-left: $o-dropdown-hpadding * 1.5;
> *:first-child {
padding-left: $o-dropdown-hpadding * 1.5;
}
+ .o_dropdown_item:not(.o_dropdown_menu_group_entry) {
margin-top: .8em;
@@ -150,7 +158,7 @@
font-weight: $-backend-font-weight-bold;
@include hover() {
background-color: transparent;
background: none;
}
}
@@ -158,10 +166,6 @@
display: flex;
grid-area: sections;
> .o_dropdown_item {
@extend %-main-navbar-entry-padding;
}
.o_more_dropdown_section_group {
padding: $-backend-entry-vpadding $-backend-entry-hpadding;
margin-top: .8em;
@@ -173,6 +173,14 @@ export class NavBar extends Component {
this.menuService.selectMenu(menu);
}
}
getMenuItemHref(payload) {
const parts = [`menu_id=${payload.id}`];
if (payload.action) {
parts.push(`action=${payload.action.split(",")[1]}`);
}
return "#" + parts.join("&");
}
}
NavBar.template = "web.NavBar";
NavBar.components = { MenuDropdown, MenuItem };
@@ -3,19 +3,25 @@
<t t-name="web.NavBar" owl="1">
<header>
<nav class="o_main_navbar" t-on-dropdown-item-selected="onNavBarDropdownItemSelection" data-command-category="navbar">
<nav
class="o_main_navbar"
t-on-dropdown-item-selected="onNavBarDropdownItemSelection"
data-command-category="navbar"
>
<!-- Apps Menu -->
<t t-call="web.NavBar.AppsMenu">
<t t-set="apps" t-value="menuService.getApps()" />
</t>
<!-- App Brand -->
<DropdownItem
<a
t-if="currentApp"
class="o_menu_brand"
payload="currentApp"
t-att-href="getMenuItemHref(currentApp)"
t-esc="currentApp.name"
t-ref="menuBrand" />
class="o_menu_brand"
t-ref="menuBrand"
t-on-click="menuService.selectMenu(currentApp)"
/>
<!-- Current App Sections -->
<t t-if="currentAppSections.length" t-call="web.NavBar.SectionsMenu">
@@ -37,11 +43,15 @@
<i class="fa fa-th-large" />
<t t-set-slot="menu">
<MenuItem
t-foreach="apps" t-as="app" t-key="app.id"
t-foreach="apps"
t-as="app"
t-key="app.id"
class="o_app"
t-att-class="{ o_dropdown_active: menuService.getCurrentApp() === app }"
payload="app"
t-esc="app.name" />
>
<a t-att-href="getMenuItemHref(app)" t-esc="app.name" />
</MenuItem>
</t>
</Dropdown>
</t>
@@ -50,22 +60,34 @@
<ul class="o_menu_sections" t-ref="appSubMenus" role="menu">
<t t-foreach="sections" t-as="section" t-key="section.id">
<t t-set="sectionsVisibleCount" t-value="(sections.length - currentAppSectionsExtra.length)" />
<t
t-set="sectionsVisibleCount"
t-value="(sections.length - currentAppSectionsExtra.length)"
/>
<t t-if="section_index lt Math.min(10, sectionsVisibleCount)">
<t t-set="hotkey" t-value="((section_index + 1) % 10).toString()"/>
<t t-set="hotkey" t-value="((section_index + 1) % 10).toString()" />
</t>
<t t-else="">
<t t-set="hotkey" t-value="undefined"/>
<t t-set="hotkey" t-value="undefined" />
</t>
<t t-if="!section.childrenTree.length">
<MenuItem title="section.name" payload="section" hotkey="hotkey">
<span t-esc="section.name" t-att-data-section="section.id" />
<MenuItem title="section.name" class="o_nav_entry" payload="section" hotkey="hotkey">
<a
t-att-href="getMenuItemHref(section)"
t-att-data-section="section.id"
t-esc="section.name"
/>
</MenuItem>
</t>
<t t-else="">
<MenuDropdown hotkey="hotkey" title="section.name" xmlid="section.xmlid">
<MenuDropdown
tag="'li'"
hotkey="hotkey"
title="section.name"
xmlid="section.xmlid"
>
<span t-esc="section.name" t-att-data-section="section.id" />
<t t-set-slot="menu">
<t t-call="web.NavBar.SectionsMenu.Dropdown.MenuSlot">
@@ -79,7 +101,7 @@
<t t-if="currentAppSectionsExtra.length" t-call="web.NavBar.SectionsMenu.MoreDropdown">
<t t-set="sections" t-value="currentAppSectionsExtra" />
<t t-if="sectionsVisibleCount lt 10">
<t t-set="hotkey" t-value="(sectionsVisibleCount + 1 % 10).toString()"/>
<t t-set="hotkey" t-value="(sectionsVisibleCount + 1 % 10).toString()" />
</t>
</t>
@@ -88,7 +110,9 @@
<t t-name="web.NavBar.SectionsMenu.Dropdown.MenuSlot" owl="1">
<t t-foreach="items" t-as="item" t-key="item.id">
<MenuItem t-if="!item.childrenTree.length" payload="item" t-esc="item.name" />
<MenuItem t-if="!item.childrenTree.length" payload="item">
<a t-att-href="getMenuItemHref(item)" t-esc="item.name" />
</MenuItem>
<t t-else="">
<div class="o_dropdown_menu_group" t-esc="item.name" />
@@ -97,28 +121,29 @@
t-as="subItem"
t-key="subItem.id"
class="o_dropdown_menu_group_entry"
payload="subItem"
t-esc="subItem.name" />
payload="subItem">
<a t-att-href="getMenuItemHref(subItem)" t-esc="subItem.name" />
</MenuItem>
</t>
</t>
</t>
<t t-name="web.NavBar.SectionsMenu.MoreDropdown" owl="1">
<Dropdown class="o_menu_sections_more" title="'More Menu'" hotkey="hotkey">
<i class="fa fa-plus"/>
<Dropdown tag="'li'" class="o_menu_sections_more" title="'More Menu'" hotkey="hotkey">
<i class="fa fa-plus" />
<t t-set-slot="menu">
<t t-foreach="sections" t-as="section" t-key="section.id">
<t t-if="!section.childrenTree.length">
<MenuItem
class="o_more_dropdown_section"
payload="section"
t-esc="section.name" />
<MenuItem class="o_more_dropdown_section" payload="section">
<a t-att-href="getMenuItemHref(section)" t-esc="section.name" />
</MenuItem>
</t>
<t t-else="">
<div
class="o_more_dropdown_section o_more_dropdown_section_group bg-200 font-weight-bold"
t-esc="section.name" />
t-esc="section.name"
/>
<t t-call="web.NavBar.SectionsMenu.Dropdown.MenuSlot">
<t t-set="items" t-value="section.childrenTree" />
</t>
@@ -47,12 +47,6 @@ export class UserMenu extends Component {
onDropdownItemSelected(ev) {
ev.detail.payload.callback();
}
onClickOnTagA(ev) {
if (!ev.ctrlKey) {
ev.preventDefault();
}
}
}
UserMenu.template = "web.UserMenu";
UserMenu.components = { UserMenuItem };
@@ -9,8 +9,8 @@
<t t-if="!element.hide">
<t t-if="element.type == 'item'">
<UserMenuItem payload="{ callback: element.callback, id: element.id }">
<a t-if="element.href" t-att-href="element.href" t-esc="element.description" t-on-click="onClickOnTagA"/>
<t t-else="" t-esc="element.description"/>
<a t-if="element.href" t-att-href="element.href" t-esc="element.description"/>
<span t-else="" t-esc="element.description"/>
</UserMenuItem>
</t>
<div t-if="element.type == 'separator'" role="separator" class="dropdown-divider"/>
+20 -1
View File
@@ -7,7 +7,8 @@ import { ActionContainer } from "./actions/action_container";
import { NavBar } from "./navbar/navbar";
import { useEffect } from "@web/core/effect_hook";
const { Component } = owl;
const { Component, hooks } = owl;
const { useExternalListener } = hooks;
const mainComponentRegistry = registry.category("main_components");
export class WebClient extends Component {
@@ -32,6 +33,7 @@ export class WebClient extends Component {
},
() => []
);
useExternalListener(window, "click", this.onGlobalClick, { capture: true });
}
mounted() {
@@ -81,6 +83,23 @@ export class WebClient extends Component {
return this.menuService.selectMenu(firstApp);
}
}
/**
* @param {MouseEvent} ev
*/
onGlobalClick(ev) {
// When a ctrl-click occurs inside an <a href/> element
// we let the browser do the default behavior and
// we do not want any other listener to execute.
if (
ev.ctrlKey &&
((ev.target instanceof HTMLAnchorElement && ev.target.href) ||
(ev.target instanceof HTMLElement && ev.target.closest("a[href]:not([href=''])")))
) {
ev.stopImmediatePropagation();
return;
}
}
}
WebClient.components = { ActionContainer, NavBar };
WebClient.template = "web.WebClient";
@@ -30,10 +30,33 @@ QUnit.module("Components", (hooks) => {
class Parent extends owl.Component {}
Parent.template = owl.tags.xml`<Dropdown/>`;
parent = await mount(Parent, { env, target });
assert.strictEqual(
parent.el.outerHTML,
'<div class="o_dropdown"><button class="o_dropdown_toggler "><span></span></button></div>'
);
assert.containsOnce(parent.el, "button.o_dropdown_toggler");
assert.containsNone(parent.el, "ul.o_dropdown_menu");
});
QUnit.test("can be rendered (custom tag)", async (assert) => {
class Parent extends owl.Component {}
Parent.template = owl.tags.xml`<Dropdown tag="'ged'"/>`;
parent = await mount(Parent, { env, target });
assert.strictEqual(
parent.el.outerHTML,
'<ged class="o_dropdown"><button class="o_dropdown_toggler "><span></span></button></ged>'
);
assert.containsOnce(parent.el, "button.o_dropdown_toggler");
assert.containsNone(parent.el, "ul.o_dropdown_menu");
});
QUnit.test("DropdownItem can be rendered", async (assert) => {
class Parent extends owl.Component {}
Parent.template = owl.tags.xml`<DropdownItem>coucou</DropdownItem>`;
parent = await mount(Parent, { env, target });
assert.strictEqual(parent.el.outerHTML, '<li class="o_dropdown_item">coucou</li>');
});
QUnit.test("can be styled", async (assert) => {
class Parent extends owl.Component {}
Parent.template = owl.tags.xml`
@@ -12,7 +12,7 @@ import { menuService } from "@web/webclient/menu_service";
import { WebClient } from "@web/webclient/webclient";
import { clearRegistryWithCleanup, makeTestEnv } from "../helpers/mock_env";
import { fakeTitleService } from "../helpers/mock_services";
import { getFixture, patchWithCleanup } from "../helpers/utils";
import { getFixture, patchWithCleanup, triggerEvent } from "../helpers/utils";
const { Component, tags, mount } = owl;
const { xml } = tags;
@@ -43,6 +43,7 @@ QUnit.test("can be rendered", async (assert) => {
const target = getFixture();
const webClient = await mount(WebClient, { env, target });
assert.containsOnce(webClient.el, "header > nav.o_main_navbar");
webClient.destroy();
});
QUnit.test("can render a main component", async (assert) => {
@@ -55,6 +56,7 @@ QUnit.test("can render a main component", async (assert) => {
const target = getFixture();
const webClient = await mount(WebClient, { env, target });
assert.containsOnce(webClient.el, ".chocolate");
webClient.destroy();
});
QUnit.test("webclient for the superuser", async (assert) => {
@@ -64,6 +66,7 @@ QUnit.test("webclient for the superuser", async (assert) => {
const target = getFixture();
const webClient = await mount(WebClient, { env, target });
assert.hasClass(webClient.el, "o_is_superuser");
webClient.destroy();
});
QUnit.test("webclient for a non superuser", async (assert) => {
@@ -73,4 +76,55 @@ QUnit.test("webclient for a non superuser", async (assert) => {
const target = getFixture();
const webClient = await mount(WebClient, { env, target });
assert.doesNotHaveClass(webClient.el, "o_is_superuser");
webClient.destroy();
});
QUnit.test("control-click propagation stopped on <a href/>", async (assert) => {
assert.expect(8);
patchWithCleanup(WebClient.prototype, {
/** @param {MouseEvent} ev */
onGlobalClick(ev) {
this._super(ev);
if (ev.ctrlKey) {
assert.ok(
ev.defaultPrevented === false,
"the global click should not prevent the default behavior on ctrl-click an <a href/>"
);
// Necessary in order to prevent the test browser to open in new tab on ctrl-click
ev.preventDefault();
}
},
});
class MyComponent extends Component {
/** @param {MouseEvent} ev */
onclick(ev) {
assert.step(ev.ctrlKey ? "ctrl-click" : "click");
// Necessary in order to prevent the test browser to open in new tab on ctrl-click
ev.preventDefault();
}
}
MyComponent.template = xml`<a href="#" class="MyComponent" t-on-click="onclick" />`;
const target = getFixture();
let env = await makeTestEnv(baseConfig);
// Mount the component as standalone and control-click the <a href/>
const standaloneComponent = await mount(MyComponent, { env, target });
assert.verifySteps([]);
await triggerEvent(standaloneComponent.el, "", "click", { ctrlKey: false });
await triggerEvent(standaloneComponent.el, "", "click", { ctrlKey: true });
assert.verifySteps(["click", "ctrl-click"]);
standaloneComponent.destroy();
// Register the component as a main one, mount the webclient and control-click the <a href/>
clearRegistryWithCleanup(mainComponentRegistry);
mainComponentRegistry.add("mycomponent", { Component: MyComponent });
env = await makeTestEnv(baseConfig);
const webClient = await mount(WebClient, { env, target });
assert.verifySteps([]);
await triggerEvent(webClient.el, ".MyComponent", "click", { ctrlKey: false });
await triggerEvent(webClient.el, ".MyComponent", "click", { ctrlKey: true });
assert.verifySteps(["click"]);
webClient.destroy();
});
@@ -17,7 +17,7 @@ tour.stepUtils.openBuggerMenu("li.breadcrumb-item.active:contains('Quotations')"
content: _t("Let's create products."),
position: "bottom",
}, {
trigger: ".o_menu_sections .o_dropdown_item span:contains('Products')",
trigger: ".o_menu_sections .o_dropdown_item:contains('Products')",
content: _t("Let's create products."),
position: "bottom"
}, {