[FIX] website: fix dropdowns hidden in fixed headers

Since the commit [1], a new element called "o_header_hide_on_scroll" was
introduced in some header templates. This element has the feature of
gradually decreasing its height as you start scrolling the page until it
completely disappears. To reduce the height of this element without
leaving its contents fully visible (because they maintain their height),
we added an "overflow: hidden" property on it. However, this "overflow:
hidden" introduced a bug fixed by this commit. Due to this, the
dropdowns within the "o_header_hide_on_scroll" element, like "user menu"
dropdown or "language selection" dropdown, became invisible.

Steps to reproduce the bug:
- Go to Website edit mode.
- Click on the header.
- Choose the "Menu - Sales 2" template from the options.
- Select the "Fixed" Scroll Effect from the options.
- Click on the "User Menu" dropdown in the header.
- Bug: The dropdown menu does not appear. The same issue occurs if you
type something in the search bar; the dropdown with the results does not
appear either.

To fix this, we don't apply "overflow: hidden" on the
"o_header_hide_on_scroll" element when the page hasn't been scrolled
yet. And if the page has been scrolled just a bit and the user clicks on
a dropdown or the search bar, we scroll the page to the top first, and
then display the dropdown menu.

This commit also add a test tour to prevent the bug from reappearing.

[1]: https://github.com/odoo/odoo/commit/143d475c3ab8d755906807bda8edb655c6dd22eb

opw-3698887

closes odoo/odoo#154979

Signed-off-by: Guillaume Dieleman (gdi) <gdi@odoo.com>
This commit is contained in:
Benjamin Vray
2024-03-01 15:28:31 +00:00
parent c2d9c55ee8
commit 2186a97f0c
4 changed files with 171 additions and 3 deletions
+88 -2
View File
@@ -353,6 +353,40 @@ publicWidget.registry.StandardAffixedHeader = BaseAnimatedHeader.extend({
publicWidget.registry.FixedHeader = BaseAnimatedHeader.extend({
selector: 'header.o_header_fixed:not(.o_header_sidebar)',
/**
* @override
*/
start() {
const _super = this._super(...arguments);
this.dropdownToggleEls = [];
if (this.hiddenOnScrollEl) {
this.dropdownToggleEls = this.hiddenOnScrollEl.querySelectorAll(".dropdown-toggle");
for (const dropdownToggleEl of this.dropdownToggleEls) {
this.__onDropdownShow = this._onDropdownShow.bind(this);
dropdownToggleEl.addEventListener("show.bs.dropdown", this.__onDropdownShow);
}
this.searchbarEl = this.hiddenOnScrollEl
.querySelector(":not(.modal-content) > .o_searchbar_form");
if (this.searchbarEl) {
this.__onSearchbarInput = this._onSearchbarInput.bind(this);
this.searchbarEl.addEventListener("input", this.__onSearchbarInput);
}
}
return _super;
},
/**
* @override
*/
destroy() {
for (const dropdownToggleEl of this.dropdownToggleEls) {
dropdownToggleEl.removeEventListener("show.bs.dropdown", this.__onDropdownShow);
}
if (this.searchbarEl) {
this.searchbarEl.removeEventListener("input", this.__onSearchbarInput);
}
this._super(...arguments);
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
@@ -377,14 +411,28 @@ publicWidget.registry.FixedHeader = BaseAnimatedHeader.extend({
}
if (this.hiddenOnScrollEl) {
let elHeight = 0;
if (this.fixedHeader && this.searchbarEl?.matches(".show")) {
// Close the dropdown of the search bar if it's open when
// scrolling. Otherwise, the calculated height of the
// 'hiddenOnScrollEl' element will be incorrect because it will
// include the dropdown height.
this.searchbarEl.querySelector("input").blur();
elHeight = this.hiddenOnScrollEl.offsetHeight;
} else {
elHeight = this.hiddenOnScrollEl.scrollHeight;
}
const scrollDelta = window.matchMedia(`(prefers-reduced-motion: reduce)`).matches ?
scroll : Math.floor(scroll / 4);
const elHeight = Math.max(0, this.hiddenOnScrollEl.scrollHeight - scrollDelta);
elHeight = Math.max(0, elHeight - scrollDelta);
this.hiddenOnScrollEl.classList.toggle("hidden", elHeight === 0);
if (elHeight === 0) {
this.hiddenOnScrollEl.removeAttribute("style");
} else {
this.hiddenOnScrollEl.style.overflow = "hidden";
// When the page hasn't been scrolled yet, we don't set overflow
// to hidden. Without this, the dropdowns would be invisible.
// (e.g., "user menu" dropdown).
this.hiddenOnScrollEl.style.overflow = this.fixedHeader ? "hidden" : "";
this.hiddenOnScrollEl.style.height = `${elHeight}px`;
let elPadding = parseInt(getComputedStyle(this.hiddenOnScrollEl).paddingBlock);
if (elHeight < elPadding * 2) {
@@ -396,6 +444,44 @@ publicWidget.registry.FixedHeader = BaseAnimatedHeader.extend({
this.hiddenOnScrollEl.style.paddingBlock = "";
}
}
if (!this.fixedHeader && this.dropdownClickedEl) {
const dropdown = Dropdown.getOrCreateInstance(this.dropdownClickedEl);
dropdown.show();
this.dropdownClickedEl = null;
}
}
},
/**
* Called when a dropdown within 'this.hiddenOnScrollEl' is clicked.
*
* @private
* @param {Event} ev
*/
_onDropdownShow(ev) {
// If a dropdown inside the element 'this.hiddenOnScrollEl' is clicked
// while the header is fixed, we need to scroll the page up so that the
// 'this.hiddenOnScrollEl' element is no longer overflow hidden. Without
// this, the dropdown would be invisible.
if (this.fixedHeader) {
ev.preventDefault();
this.scrollableEl.scrollTo({ top: 0, behavior: "smooth" });
this.dropdownClickedEl = ev.currentTarget;
}
},
/**
* Called when a searchbar within 'this.hiddenOnScrollEl' receives input.
*
* @private
* @param {Event} ev
*/
_onSearchbarInput(ev) {
// Prevents the dropdown with search results from being hidden when the
// header is fixed (see comment in '_onDropdownClick').
// The scroll animation is instantaneous because the dropdown could open
// before reaching the top of the page, which would result in an
// incorrect calculated height of the header.
if (this.fixedHeader) {
this.scrollableEl.scrollTo({ top: 0 });
}
},
});
@@ -140,6 +140,30 @@ function changePaddingSize(direction) {
};
}
/**
* Checks if an element is visible on the screen, i.e., not masked by another
* element.
*
* @param {String} elementSelector The selector of the element to be checked.
* @returns {Object} The steps required to check if the element is visible.
*/
function checkIfVisibleOnScreen(elementSelector) {
return {
content: "Check if the element is visible on screen",
trigger: `${elementSelector}`,
run() {
const boundingRect = this.$anchor[0].getBoundingClientRect();
const centerX = boundingRect.left + boundingRect.width / 2;
const centerY = boundingRect.top + boundingRect.height / 2;
const iframeDocument = document.querySelector(".o_iframe").contentDocument;
const el = iframeDocument.elementFromPoint(centerX, centerY);
if (!this.$anchor[0].contains(el)) {
console.error("The element is not visible on screen");
}
},
};
}
/**
* Simple click on an element in the page.
* @param {*} elementName
@@ -187,7 +211,7 @@ function clickOnSnippet(snippet, position = "bottom") {
};
}
function clickOnSave(position = "bottom") {
function clickOnSave(position = "bottom", timeout) {
return [{
trigger: "div:not(.o_loading_dummy) > #oe_snippets button[data-action=\"save\"]:not([disabled])",
// TODO this should not be needed but for now it better simulates what
@@ -201,6 +225,7 @@ function clickOnSave(position = "bottom") {
in_modal: false,
content: markup(_t("Good job! It's time to <b>Save</b> your work.")),
position: position,
timeout: timeout,
}, {
trigger: 'iframe body:not(.editor_enable)',
noPrepend: true,
@@ -456,6 +481,7 @@ export default {
changeImage,
changeOption,
changePaddingSize,
checkIfVisibleOnScreen,
clickOnEditAndWaitEditMode,
clickOnElement,
clickOnExtraMenuItem,
@@ -0,0 +1,53 @@
/** @odoo-module */
import wTourUtils from "@website/js/tours/tour_utils";
const checkIfUserMenuNotMasked = function () {
return [
{
content: "Click on the user dropdown",
trigger: "iframe #wrapwrap header .o_header_hide_on_scroll li.dropdown > a",
},
wTourUtils.checkIfVisibleOnScreen("iframe #wrapwrap header .o_header_hide_on_scroll li.dropdown .dropdown-menu.show a[href='/my/home']"),
];
};
const scrollDownToMediaList = function () {
return {
content: "Scroll down the page a little to leave the dropdown partially visible",
trigger: "iframe #wrapwrap .s_media_list",
run: function () {
// Scroll down to the media list snippet.
this.$anchor[0].scrollIntoView(true);
},
};
};
wTourUtils.registerWebsitePreviewTour("dropdowns_and_header_hide_on_scroll", {
test: true,
url: "/",
edition: true,
}, () => [
wTourUtils.dragNDrop({id: "s_media_list", name: "Media List"}),
wTourUtils.selectHeader(),
wTourUtils.changeOption("undefined", 'we-select[data-variable="header-scroll-effect"]'),
wTourUtils.changeOption("undefined", 'we-button[data-name="header_effect_fixed_opt"]'),
wTourUtils.changeOption("HeaderLayout", 'we-select[data-variable="header-template"] we-toggler'),
wTourUtils.changeOption("HeaderLayout", 'we-button[data-name="header_sales_two_opt"]'),
...wTourUtils.clickOnSave(undefined, 30000),
...checkIfUserMenuNotMasked(),
// We scroll the page a little because when clicking on the dropdown, the
// page needs to scroll to the top first and then open the dropdown menu.
scrollDownToMediaList(),
...checkIfUserMenuNotMasked(),
// We scroll the page again because when typing in the searchbar input, the
// page needs also to scroll to the top first and then open the dropdown
// with the search results.
scrollDownToMediaList(),
{
content: "Type a search query into the searchbar input",
trigger: "iframe #wrapwrap header .s_searchbar_input input.search-query",
run: "text a",
},
wTourUtils.checkIfVisibleOnScreen("iframe #wrapwrap header .s_searchbar_input.show .o_dropdown_menu.show"),
]);
+3
View File
@@ -126,3 +126,6 @@ class TestSnippets(HttpCase):
'url': base + '/web/image/website.s_banner_default_image',
})
self.start_tour(self.env['website'].get_client_action_url('/'), 'snippet_image_gallery_thumbnail_update', login='admin')
def test_dropdowns_and_header_hide_on_scroll(self):
self.start_tour(self.env['website'].get_client_action_url('/'), 'dropdowns_and_header_hide_on_scroll', login='admin')