diff --git a/addons/website/static/src/interactions/_example.js b/addons/website/static/src/interactions/_example.js index 010423644c06a..dcf1fffba78ba 100644 --- a/addons/website/static/src/interactions/_example.js +++ b/addons/website/static/src/interactions/_example.js @@ -34,9 +34,11 @@ class TogglableBackgroundSection extends Interaction { } } +/* registry .category("website.active_elements") .add("website.toggle_background", TogglableBackgroundSection); +*/ // ----------------------------------------------------------------------------- // Example of interaction @@ -53,9 +55,11 @@ class FunNotificationThing extends Interaction { } } +/* registry .category("website.active_elements") .add("website.fun_notification", FunNotificationThing); +*/ // ----------------------------------------------------------------------------- // Example of mounted component @@ -79,4 +83,6 @@ class Counter extends Component { } } +/* registry.category("website.active_elements").add("website.counter", Counter); +*/ diff --git a/addons/website/static/src/interactions/anchor_slide.js b/addons/website/static/src/interactions/anchor_slide.js new file mode 100644 index 0000000000000..84e6badbfbe08 --- /dev/null +++ b/addons/website/static/src/interactions/anchor_slide.js @@ -0,0 +1,99 @@ +import { registry } from "@web/core/registry"; +import { Interaction } from "@website/core/interaction"; +import { scrollTo } from "@web_editor/js/common/scrolling"; + +export class AnchorSlide extends Interaction { + static selector = "a[href^='/'][href*='#'], a[href^='#']"; + static dynamicContent = { + "_root": { + "t-on-click": "animateClick", + }, + }; + + /** + * @param {DOMElement} el the element to scroll to. + * @param {string} [scrollValue='true'] scroll value + * @returns {Promise} + */ + scrollTo(el, scrollValue="true") { + return scrollTo(el, { + duration: scrollValue === "true" ? 500 : 0, + extraOffset: this.computeExtraOffset(), + }); + } + /** + * To be overridden. + */ + computeExtraOffset() { + return 0; + } + /** + */ + animateClick(ev) { + const ensureSlash = path => path.endsWith("/") ? path : path + "/"; + if (ensureSlash(this.el.pathname) !== ensureSlash(window.location.pathname)) { + return; + } + // Avoid flicker at destination in case of ending "/" difference. + if (this.el.pathname !== window.location.pathname) { + this.el.pathname = window.location.pathname; + } + let hash = this.el.hash; + if (!hash.length) { + return; + } + // Escape special characters to make the selector work. + // TODO Not rely on jQuery to escape anchor name for selector. + hash = "#" + $.escapeSelector(hash.substring(1)); + const anchorEl = this.el.ownerDocument.querySelector(hash); + const scrollValue = anchorEl?.dataset.anchor; + if (!anchorEl || !scrollValue) { + return; + } + + const offcanvasEl = this.el.closest(".offcanvas.o_navbar_mobile"); + if (offcanvasEl && offcanvasEl.classList.contains("show")) { + // Special case for anchors in offcanvas in mobile: we can't just + // scrollTo() after preventDefault because preventDefault would + // prevent the offcanvas to be closed. The choice is then to close + // it ourselves manually and once it's fully closed, then start our + // own smooth scrolling. + ev.preventDefault(); + Offcanvas.getInstance(offcanvasEl).hide(); + offcanvasEl.addEventListener("hidden.bs.offcanvas", + () => { + this.manageScroll(hash, anchorEl, scrollValue); + }, + // the listener must be automatically removed when invoked + { once: true } + ); + } else { + ev.preventDefault(); + this.manageScroll(hash, anchorEl, scrollValue); + } + } + /** + * + * @param {string} hash + * @param {DOMElement} anchorEl the element to scroll to. + * @param {string} [scrollValue='true'] scroll value + */ + manageScroll(hash, anchorEl, scrollValue = "true") { + if (hash === "#top" || hash === "#bottom") { + // If the anchor targets #top or #bottom, directly call the + // "scrollTo" function. The reason is that the header or the footer + // could have been removed from the DOM. By receiving a string as + // parameter, the "scrollTo" function handles the scroll to the top + // or to the bottom of the document even if the header or the + // footer is removed from the DOM. + scrollTo(hash, { + duration: 500, + extraOffset: this.computeExtraOffset(), + }); + } else { + this.scrollTo(anchorEl, scrollValue); + } + } +} + +registry.category("website.active_elements").add("website.anchor_slide", AnchorSlide); diff --git a/addons/website/static/src/interactions/scroll_button.js b/addons/website/static/src/interactions/scroll_button.js new file mode 100644 index 0000000000000..deb686585d5b9 --- /dev/null +++ b/addons/website/static/src/interactions/scroll_button.js @@ -0,0 +1,23 @@ +import { registry } from "@web/core/registry"; +import { isVisible } from "@web/core/utils/ui"; +import { AnchorSlide } from "@website/interactions/anchor_slide"; + +export class ScrollButton extends AnchorSlide { + static selector = ".o_scroll_button"; + + animateClick(ev) { + ev.preventDefault(); + // Scroll to the next visible element after the current one. + const currentSectionEl = this.el.closest("section"); + let nextEl = currentSectionEl.nextElementSibling; + while (nextEl) { + if (isVisible(nextEl)) { + this.scrollTo(nextEl); + return; + } + nextEl = nextEl.nextElementSibling; + } + } +} + +registry.category("website.active_elements").add("website.scroll_button", ScrollButton); diff --git a/addons/website/static/src/js/content/snippets.animation.js b/addons/website/static/src/js/content/snippets.animation.js index 0ca8632e214a1..29c482a870953 100644 --- a/addons/website/static/src/js/content/snippets.animation.js +++ b/addons/website/static/src/js/content/snippets.animation.js @@ -1126,109 +1126,6 @@ registry.backgroundVideo = publicWidget.Widget.extend(MobileYoutubeAutoplayMixin }, }); -registry.anchorSlide = publicWidget.Widget.extend({ - selector: 'a[href^="/"][href*="#"], a[href^="#"]', - events: { - 'click': '_onAnimateClick', - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - * @param {jQuery} $el the element to scroll to. - * @param {string} [scrollValue='true'] scroll value - * @returns {Promise} - */ - async _scrollTo($el, scrollValue = 'true') { - return scrollTo($el[0], { - duration: scrollValue === "true" ? 500 : 0, - extraOffset: this._computeExtraOffset(), - }); - }, - /** - * @private - */ - _computeExtraOffset() { - return 0; - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * @private - */ - _onAnimateClick: function (ev) { - const ensureSlash = path => path.endsWith("/") ? path : path + "/"; - if (ensureSlash(this.el.pathname) !== ensureSlash(window.location.pathname)) { - return; - } - // Avoid flicker at destination in case of ending "/" difference. - if (this.el.pathname !== window.location.pathname) { - this.el.pathname = window.location.pathname; - } - var hash = this.el.hash; - if (!hash.length) { - return; - } - // Escape special characters to make the jQuery selector to work. - hash = '#' + $.escapeSelector(hash.substring(1)); - var $anchor = $(hash); - const scrollValue = $anchor.attr('data-anchor'); - if (!$anchor.length || !scrollValue) { - return; - } - - const offcanvasEl = this.el.closest('.offcanvas.o_navbar_mobile'); - if (offcanvasEl && offcanvasEl.classList.contains('show')) { - // Special case for anchors in offcanvas in mobile: we can't just - // _scrollTo() after preventDefault because preventDefault would - // prevent the offcanvas to be closed. The choice is then to close - // it ourselves manually and once it's fully closed, then start our - // own smooth scrolling. - ev.preventDefault(); - Offcanvas.getInstance(offcanvasEl).hide(); - offcanvasEl.addEventListener('hidden.bs.offcanvas', - () => { - this._manageScroll(hash, $anchor, scrollValue); - }, - // the listener must be automatically removed when invoked - { once: true } - ); - } else { - ev.preventDefault(); - this._manageScroll(hash, $anchor, scrollValue); - } - }, - /** - * - * @param {string} hash - * @param {jQuery} $el the element to scroll to. - * @param {string} [scrollValue='true'] scroll value - * @private - */ - _manageScroll(hash, $anchor, scrollValue = "true") { - if (hash === "#top" || hash === "#bottom") { - // If the anchor targets #top or #bottom, directly call the - // "scrollTo" function. The reason is that the header or the footer - // could have been removed from the DOM. By receiving a string as - // parameter, the "scrollTo" function handles the scroll to the top - // or to the bottom of the document even if the header or the - // footer is removed from the DOM. - scrollTo(hash, { - duration: 500, - extraOffset: this._computeExtraOffset(), - }); - } else { - this._scrollTo($anchor, scrollValue); - } - }, -}); - registry.FullScreenHeight = publicWidget.Widget.extend({ selector: '.o_full_screen_height', disabledInEditableMode: false, @@ -1288,27 +1185,6 @@ registry.FullScreenHeight = publicWidget.Widget.extend({ }, }); -registry.ScrollButton = registry.anchorSlide.extend({ - selector: '.o_scroll_button', - - /** - * @override - */ - _onAnimateClick: function (ev) { - ev.preventDefault(); - // Scroll to the next visible element after the current one. - const currentSectionEl = this.el.closest('section'); - let nextEl = currentSectionEl.nextElementSibling; - while (nextEl) { - if ($(nextEl).is(':visible')) { - this._scrollTo($(nextEl)); - return; - } - nextEl = nextEl.nextElementSibling; - } - }, -}); - registry.FooterSlideout = publicWidget.Widget.extend({ selector: '#wrapwrap', selectorHas: '.o_footer_slideout', diff --git a/addons/website/static/src/snippets/s_table_of_content/000.js b/addons/website/static/src/snippets/s_table_of_content/000.js index 0a9d013e13b2f..27853fc3cb9cc 100644 --- a/addons/website/static/src/snippets/s_table_of_content/000.js +++ b/addons/website/static/src/snippets/s_table_of_content/000.js @@ -1,6 +1,9 @@ +import { registry } from "@web/core/registry"; +import { patch } from "@web/core/utils/patch"; import publicWidget from "@web/legacy/js/public/public_widget"; import {extraMenuUpdateCallbacks} from "@website/js/content/menu"; import { closestScrollable } from "@web_editor/js/common/scrolling"; +import { AnchorSlide } from "@website/interactions/anchor_slide"; const CLASS_NAME_DROPDOWN_ITEM = 'dropdown-item'; const CLASS_NAME_ACTIVE = 'active'; @@ -222,25 +225,19 @@ const TableOfContent = publicWidget.Widget.extend({ }, }); -publicWidget.registry.anchorSlide.include({ - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - +patch(AnchorSlide.prototype, { /** * Overridden to add the height of the horizontal sticky navbar at the scroll value * when the link is from the table of content navbar * * @override - * @private */ - _computeExtraOffset() { - let extraOffset = this._super(...arguments); - if (this.$el.hasClass('table_of_content_link')) { - const tableOfContentNavbarEl = this.$el.closest('.s_table_of_content_navbar_sticky.s_table_of_content_horizontal_navbar'); - if (tableOfContentNavbarEl.length > 0) { - extraOffset += $(tableOfContentNavbarEl).outerHeight(); + computeExtraOffset() { + let extraOffset = super.computeExtraOffset(...arguments); + if (this.el.classList.contains("table_of_content_link")) { + const tableOfContentNavbarEl = this.el.closest(".s_table_of_content_navbar_sticky.s_table_of_content_horizontal_navbar"); + if (tableOfContentNavbarEl) { + extraOffset += tableOfContentNavbarEl.getBoundingClientRect().height; } } return extraOffset; diff --git a/addons/website/static/tests/core/helpers.js b/addons/website/static/tests/core/helpers.js index 55f3f3a61d296..6c9ffd7050029 100644 --- a/addons/website/static/tests/core/helpers.js +++ b/addons/website/static/tests/core/helpers.js @@ -56,3 +56,13 @@ export function mockSendRequests() { }); return requests; } + +export function isElementInViewport(el) { + const rect = el.getBoundingClientRect(); + return ( + rect.top >= 0 && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); +} diff --git a/addons/website/static/tests/interactions/anchor_slide.test.js b/addons/website/static/tests/interactions/anchor_slide.test.js new file mode 100644 index 0000000000000..aac46d7c01146 --- /dev/null +++ b/addons/website/static/tests/interactions/anchor_slide.test.js @@ -0,0 +1,57 @@ +import { expect, test } from "@odoo/hoot"; +import { animationFrame, click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { + patchWithCleanup, +} from "@web/../tests/web_test_helpers"; +import { AnchorSlide } from "@website/interactions/anchor_slide"; +import { isElementInViewport, startInteractions, setupInteractionWhiteList } from "../core/helpers"; + +setupInteractionWhiteList("website.anchor_slide"); + +test("anchor slide does nothing if there is no href", async () => { + const { core } = await startInteractions(` +
+ `); + expect(core.interactions.length).toBe(0); +}); + +test("anchor slide scrolls to targetted location", async () => { + const { core, el } = await startInteractions(` + + `); + expect(core.interactions.length).toBe(1); + const aEl = el.querySelector("a[href]"); + const targetEl = el.querySelector("div#target"); + expect(isElementInViewport(targetEl)).toBe(false); + click(aEl); + expect(isElementInViewport(targetEl)).toBe(false); + await animationFrame(); + await advanceTime(500); // Duration defined in AnchorSlide. + expect(isElementInViewport(targetEl)).toBe(true); +}); + +test("without anchor slide instantly reach the targetted location", async () => { + const { core, el } = await startInteractions(` + + `); + expect(core.interactions.length).toBe(1); + core.stopInteractions(); + expect(core.interactions.length).toBe(0); + const aEl = el.querySelector("a[href]"); + const targetEl = el.querySelector("div#target"); + expect(isElementInViewport(targetEl)).toBe(false); + click(aEl); + await animationFrame(); + expect(isElementInViewport(targetEl)).toBe(true); +}); diff --git a/addons/website/static/tests/interactions/scroll_button.test.js b/addons/website/static/tests/interactions/scroll_button.test.js new file mode 100644 index 0000000000000..b2de3e3051291 --- /dev/null +++ b/addons/website/static/tests/interactions/scroll_button.test.js @@ -0,0 +1,54 @@ +import { expect, test } from "@odoo/hoot"; +import { animationFrame, click } from "@odoo/hoot-dom"; +import { advanceTime } from "@odoo/hoot-mock"; +import { isElementInViewport, startInteractions, setupInteractionWhiteList } from "../core/helpers"; + +setupInteractionWhiteList("website.scroll_button"); + +test("scroll button does nothing if there is o_scroll_button", async () => { + const { core } = await startInteractions(` +