diff --git a/addons/web/static/src/legacy/js/core/dom.js b/addons/web/static/src/legacy/js/core/dom.js index 113d75df82f..284e8111213 100644 --- a/addons/web/static/src/legacy/js/core/dom.js +++ b/addons/web/static/src/legacy/js/core/dom.js @@ -152,6 +152,13 @@ var dom = { closestScrollable(el) { return $(el).closestScrollable()[0] || null; }, + /** + * @param {HTMLElement} el + * @see $.compensateScrollbar + */ + compensateScrollbar(el, ...rest) { + $(el).compensateScrollbar(...rest); + }, /** * jQuery find function behavior is:: * diff --git a/addons/web/static/src/legacy/js/libs/bootstrap.js b/addons/web/static/src/legacy/js/libs/bootstrap.js index 307bec83d54..9cdb53ea7cd 100644 --- a/addons/web/static/src/legacy/js/libs/bootstrap.js +++ b/addons/web/static/src/legacy/js/libs/bootstrap.js @@ -158,3 +158,27 @@ ScrollSpy.prototype._activate = function (target) { Dropdown.prototype._detectNavbar = function () { return false; }; + +/* Bootstrap modal scrollbar compensation on non-body */ +const bsAdjustDialogFunction = Modal.prototype._adjustDialog; +Modal.prototype._adjustDialog = function () { + const document = this._element.ownerDocument; + document.body.classList.remove('modal-open'); + const $scrollable = $().getScrollingElement(document); + if (document.body.contains($scrollable[0])) { + $scrollable.compensateScrollbar(true); + } + document.body.classList.add('modal-open'); + return bsAdjustDialogFunction.apply(this, arguments); +}; + +const bsResetAdjustmentsFunction = Modal.prototype._resetAdjustments; +Modal.prototype._resetAdjustments = function () { + const document = this._element.ownerDocument; + document.body.classList.remove('modal-open'); + const $scrollable = $().getScrollingElement(document); + if (document.body.contains($scrollable[0])) { + $scrollable.compensateScrollbar(false); + } + return bsResetAdjustmentsFunction.apply(this, arguments); +}; diff --git a/addons/web/static/src/legacy/js/libs/jquery.js b/addons/web/static/src/legacy/js/libs/jquery.js index 1089b44bc3d..61236bfa485 100644 --- a/addons/web/static/src/legacy/js/libs/jquery.js +++ b/addons/web/static/src/legacy/js/libs/jquery.js @@ -146,6 +146,37 @@ $.fn.extend({ } return $el; }, + /** + * Adapt the given css property by adding the size of a scrollbar if any. + * Limitation: only works if the given css property is not already used as + * inline style for another reason. + * + * @param {boolean} [add=true] + * @param {boolean} [isScrollElement=true] + * @param {string} [cssProperty='padding-right'] + */ + compensateScrollbar(add = true, isScrollElement = true, cssProperty = 'padding-right') { + for (const el of this) { + // Compensate scrollbar + const scrollableEl = isScrollElement ? el : $(el).parent().closestScrollable()[0]; + const isRTL = scrollableEl.matches(".o_rtl"); + if (isRTL) { + cssProperty = cssProperty.replace("right", "left"); + } + el.style.removeProperty(cssProperty); + if (!add) { + return; + } + const style = window.getComputedStyle(el); + // Round up to the nearest integer to be as close as possible to + // the correct value in case of browser zoom. + const borderLeftWidth = Math.ceil(parseFloat(style.borderLeftWidth.replace('px', ''))); + const borderRightWidth = Math.ceil(parseFloat(style.borderRightWidth.replace('px', ''))); + const bordersWidth = borderLeftWidth + borderRightWidth; + const newValue = parseInt(style[cssProperty]) + scrollableEl.offsetWidth - scrollableEl.clientWidth - bordersWidth; + el.style.setProperty(cssProperty, `${newValue}px`, 'important'); + } + }, /** * @returns {jQuery} */ diff --git a/addons/web/static/src/legacy/scss/base_frontend.scss b/addons/web/static/src/legacy/scss/base_frontend.scss index 585ce965ec5..c300b3db38e 100644 --- a/addons/web/static/src/legacy/scss/base_frontend.scss +++ b/addons/web/static/src/legacy/scss/base_frontend.scss @@ -24,3 +24,6 @@ html, body, #wrapwrap { flex: 1 0 auto; } } +.modal-open #wrapwrap { + overflow: hidden; +} diff --git a/addons/web_editor/static/src/js/editor/snippets.editor.js b/addons/web_editor/static/src/js/editor/snippets.editor.js index 0b2ed758153..abd20587640 100644 --- a/addons/web_editor/static/src/js/editor/snippets.editor.js +++ b/addons/web_editor/static/src/js/editor/snippets.editor.js @@ -409,6 +409,12 @@ var SnippetEditor = Widget.extend({ const editableOffsetTop = this.$editable.offset().top - manipulatorOffset.top; this.$el.toggleClass('o_top_cover', offset.top - editableOffsetTop < 25); + // If the element covered by the overlay has a scrollbar, we remove its + // right border as it interferes with proper scrolling. (e.g. modal) + const handleEReadonlyEl = this.$el[0].querySelector('.o_handle.e.readonly'); + if (handleEReadonlyEl) { + handleEReadonlyEl.style.width = dom.hasScrollableContent(targetEl) ? 0 : ''; + } }, /** * DOMElements have a default name which appears in the overlay when they diff --git a/addons/website/static/src/js/content/menu.js b/addons/website/static/src/js/content/menu.js index 10be759b2b3..48b98f1d06c 100644 --- a/addons/website/static/src/js/content/menu.js +++ b/addons/website/static/src/js/content/menu.js @@ -4,6 +4,7 @@ import config from "web.config"; import publicWidget from "web.public.widget"; import animations from "website.content.snippets.animation"; const extraMenuUpdateCallbacks = []; +import dom from "web.dom"; const BaseAnimatedHeader = animations.Animation.extend({ disabledInEditableMode: false, @@ -78,17 +79,7 @@ const BaseAnimatedHeader = animations.Animation.extend({ * @private */ _adaptFixedHeaderPosition() { - // Compensate scrollbar - this.el.style.removeProperty('right'); - if (this.fixedHeader) { - const scrollableEl = $(this.el).parent().closestScrollable()[0]; - const style = window.getComputedStyle(this.el); - const borderLeftWidth = parseInt(style.borderLeftWidth.replace('px', '')); - const borderRightWidth = parseInt(style.borderRightWidth.replace('px', '')); - const bordersWidth = borderLeftWidth + borderRightWidth; - const newValue = parseInt(style['right']) + scrollableEl.offsetWidth - scrollableEl.clientWidth - bordersWidth; - this.el.style.setProperty('right', `${newValue}px`, 'important'); - } + dom.compensateScrollbar(this.el, this.fixedHeader, false, 'right'); }, /** * @private diff --git a/addons/website/static/src/snippets/s_popup/000.js b/addons/website/static/src/snippets/s_popup/000.js index 7c2ebca1c42..e1f12192315 100644 --- a/addons/website/static/src/snippets/s_popup/000.js +++ b/addons/website/static/src/snippets/s_popup/000.js @@ -3,6 +3,8 @@ import config from "web.config"; import publicWidget from "web.public.widget"; import {getCookie, setCookie} from "web.utils.cookies"; +import dom from "web.dom"; +import throttleForAnimation from "@web/core/utils/timing"; // TODO In master, export this class too or merge it with PopupWidget const SharedPopupWidget = publicWidget.Widget.extend({ @@ -178,6 +180,109 @@ const PopupWidget = publicWidget.Widget.extend({ publicWidget.registry.popup = PopupWidget; +const noBackdropPopupWidget = publicWidget.Widget.extend({ + selector: '.s_popup_no_backdrop', + disabledInEditableMode: false, + events: { + 'shown.bs.modal': '_onModalNoBackdropShown', + 'hide.bs.modal': '_onModalNoBackdropHide', + }, + + /** + * @override + */ + start() { + this.throttledUpdateScrollbar = throttleForAnimation(() => this._updateScrollbar()); + if (this.editableMode && this.el.classList.contains('show')) { + // Use case: When the "Backdrop" option is disabled in edit mode. + // The page scrollbar must be adjusted and events must be added. + this._updateScrollbar(); + this._addModalNoBackdropEvents(); + } + return this._super(...arguments); + }, + /** + * @override + */ + destroy() { + this._super(...arguments); + this._removeModalNoBackdropEvents(); + // After destroying the widget, we need to trigger a resize event so that + // the scrollbar can adjust to its default behavior. + window.dispatchEvent(new Event('resize')); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * @private + */ + _updateScrollbar() { + // When there is no backdrop the element with the scrollbar is + // '.modal-content' (see comments in CSS). + const modalContent = this.el.querySelector('.modal-content'); + const isOverflowing = dom.hasScrollableContent(modalContent); + const modalInstance = window.Modal.getInstance(this.el); + if (isOverflowing) { + // If the "no-backdrop" modal has a scrollbar, the page's scrollbar + // must be hidden. This is because if the two scrollbars overlap, it + // is no longer possible to scroll using the modal's scrollbar. + modalInstance._adjustDialog(); + } else { + // If the "no-backdrop" modal does not have a scrollbar, the page + // scrollbar must be displayed because we must be able to scroll the + // page (e.g. a "cookies bar" popup at the bottom of the page must + // not prevent scrolling the page). + modalInstance._resetAdjustments(); + } + }, + /** + * @private + */ + _addModalNoBackdropEvents() { + window.addEventListener('resize', this.throttledUpdateScrollbar); + this.resizeObserver = new window.ResizeObserver(() => { + // When the size of the modal changes, the scrollbar needs to be + // adjusted. + this._updateScrollbar(); + }); + this.resizeObserver.observe(this.el.querySelector('.modal-content')); + }, + /** + * @private + */ + _removeModalNoBackdropEvents() { + this.throttledUpdateScrollbar.cancel(); + window.removeEventListener('resize', this.throttledUpdateScrollbar); + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + delete this.resizeObserver; + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onModalNoBackdropShown() { + this._updateScrollbar(); + this._addModalNoBackdropEvents(); + }, + /** + * @private + */ + _onModalNoBackdropHide() { + this._removeModalNoBackdropEvents(); + }, +}); + +publicWidget.registry.noBackdropPopup = noBackdropPopupWidget; + // Extending the popup widget with cookiebar functionality. // This allows for refusing optional cookies for now and can be // extended to picking which cookies categories are accepted.