diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index e61a0df9022..76109740e0b 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -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 }); } }, }); diff --git a/addons/website/static/src/js/tours/tour_utils.js b/addons/website/static/src/js/tours/tour_utils.js index 1007d1cee93..804cad5f254 100644 --- a/addons/website/static/src/js/tours/tour_utils.js +++ b/addons/website/static/src/js/tours/tour_utils.js @@ -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 Save 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, diff --git a/addons/website/static/tests/tours/dropdowns_and_header_hide_on_scroll.js b/addons/website/static/tests/tours/dropdowns_and_header_hide_on_scroll.js new file mode 100644 index 00000000000..cb6609c72ec --- /dev/null +++ b/addons/website/static/tests/tours/dropdowns_and_header_hide_on_scroll.js @@ -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"), +]); diff --git a/addons/website/tests/test_snippets.py b/addons/website/tests/test_snippets.py index 1583a55dcd7..0ccdcb28a04 100644 --- a/addons/website/tests/test_snippets.py +++ b/addons/website/tests/test_snippets.py @@ -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')