diff --git a/addons/web/static/src/js/core/dialog.js b/addons/web/static/src/js/core/dialog.js index bb522992c2f..6e9620421c3 100644 --- a/addons/web/static/src/js/core/dialog.js +++ b/addons/web/static/src/js/core/dialog.js @@ -4,6 +4,7 @@ odoo.define('web.Dialog', function (require) { var core = require('web.core'); var dom = require('web.dom'); var Widget = require('web.Widget'); +const OwlDialog = require('web.OwlDialog'); var QWeb = core.qweb; var _t = core._t; @@ -205,6 +206,9 @@ var Dialog = Widget.extend({ if (options && options.shouldFocusButtons) { self._onFocusControlButton(); } + + // Notifies OwlDialog to adjust focus/active properties on owl dialogs + OwlDialog.display(self); }); return self; @@ -236,6 +240,9 @@ var Dialog = Widget.extend({ return; } + // Notifies OwlDialog to adjust focus/active properties on owl dialogs + OwlDialog.hide(this); + // Triggers the onForceClose event if the callback is defined if (this.onForceClose) { this.onForceClose(); diff --git a/addons/web/static/src/js/core/owl_dialog.js b/addons/web/static/src/js/core/owl_dialog.js new file mode 100644 index 00000000000..a6ee3c7b1d8 --- /dev/null +++ b/addons/web/static/src/js/core/owl_dialog.js @@ -0,0 +1,259 @@ +odoo.define('web.OwlDialog', function (require) { + "use strict"; + + const { useExternalListener } = require('web.custom_hooks'); + + const { Component, hooks, misc } = owl; + const { Portal } = misc; + const { useRef } = hooks; + const SIZE_CLASSES = { + 'extra-large': 'modal-xl', + 'large': 'modal-lg', + 'small': 'modal-sm', + }; + + /** + * Dialog (owl version) + * + * Represents a bootstrap-styled dialog handled with pure JS. Its implementation + * is roughly the same as the legacy dialog, the only exception being the buttons. + * @extends Component + **/ + class Dialog extends Component { + /** + * @param {Object} [props] + * @param {(boolean|string)} [props.backdrop='static'] The kind of modal backdrop + * to use (see Bootstrap documentation). + * @param {string} [props.contentClass] Class to add to the dialog + * @param {boolean} [props.fullscreen=false] Whether the dialog should be + * open in fullscreen mode (the main usecase is mobile). + * @param {boolean} [props.renderFooter=true] Whether the dialog footer + * should be rendered. + * @param {boolean} [props.renderHeader=true] Whether the dialog header + * should be rendered. + * @param {string} [props.size='large'] 'extra-large', 'large', 'medium' + * or 'small'. + * @param {string} [props.subtitle=''] + * @param {string} [props.title='Odoo'] + * @param {boolean} [props.technical=true] If set to false, the modal will have + * the standard frontend style (use this for non-editor frontend features). + */ + constructor() { + super(...arguments); + + this.modalRef = useRef('modal'); + this.footerRef = useRef('modal-footer'); + + useExternalListener(window, 'keydown', this._onKeydown); + } + + mounted() { + this.constructor.display(this); + + this.env.bus.on('close_dialogs', this, this._close); + + if (this.props.renderFooter) { + // Set up main button : will first look for an element with the + // 'btn-primary' class, then a 'btn' class, then the first button + // element. + let mainButton = this.footerRef.el.querySelector('.btn.btn-primary'); + if (!mainButton) { + mainButton = this.footerRef.el.querySelector('.btn'); + } + if (!mainButton) { + mainButton = this.footerRef.el.querySelector('button'); + } + if (mainButton) { + this.mainButton = mainButton; + this.mainButton.addEventListener('keydown', this._onMainButtonKeydown.bind(this)); + this.mainButton.focus(); + } + } + + this._removeTooltips(); + } + + async willUnmount() { + this.env.bus.off('close_dialogs', this, this._close); + + this._removeTooltips(); + + this.constructor.hide(this); + } + + //-------------------------------------------------------------------------- + // Getters + //-------------------------------------------------------------------------- + + /** + * @returns {string} + */ + get size() { + return SIZE_CLASSES[this.props.size]; + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Send an event signaling that the dialog must be closed. + * @private + */ + _close() { + this.trigger('dialog_closed'); + } + + /** + * Remove any existing tooltip present in the DOM. + * @private + */ + _removeTooltips() { + for (const tooltip of document.querySelectorAll('.tooltip')) { + tooltip.remove(); // remove open tooltip if any to prevent them staying when modal is opened + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + */ + _onBackdropClick() { + if (this.props.backdrop === 'static') { + if (this.mainButton) { + this.mainButton.focus(); + } + } else { + this._close(); + } + } + + /** + * @private + */ + _onFocus() { + if (this.mainButton) { + this.mainButton.focus(); + } + } + + /** + * Manage the TAB key on the main button. If the focus is on a primary + * button and the user tries to tab to go to the next button : a tooltip + * will be displayed. + * @private + * @param {KeyboardEvent} ev + */ + _onMainButtonKeydown(ev) { + if (ev.key === 'Tab' && !ev.shiftKey) { + ev.preventDefault(); + $(this.mainButton) + .tooltip({ + delay: { show: 200, hide: 0 }, + title: () => this.env.qweb.render('DialogButton.tooltip', { + title: this.mainButton.innerText.toUpperCase(), + }), + trigger: 'manual', + }) + .tooltip('show'); + } + } + + /** + * @private + * @param {KeyboardEvent} ev + */ + _onKeydown(ev) { + if ( + ev.key === 'Escape' && + !['INPUT', 'TEXTAREA'].includes(ev.target.tagName) && + this.constructor.displayed[this.constructor.displayed.length - 1] === this + ) { + ev.preventDefault(); + ev.stopImmediatePropagation(); + ev.stopPropagation(); + this._close(); + } + } + + //-------------------------------------------------------------------------- + // Static + //-------------------------------------------------------------------------- + + /** + * Push the given dialog at the end of the displayed list then set it as + * active and all the others as passive. + * @param {(LegacyDialog|OwlDialog)} dialog + */ + static display(dialog) { + // Deactivate previous dialog + const activeDialogEl = document.querySelector('.modal.o_active_modal'); + if (activeDialogEl) { + activeDialogEl.classList.remove('o_active_modal'); + } + // Push dialog + this.displayed.push(dialog); + // Add active class + const modalEl = dialog instanceof this ? + // Owl dialog + dialog.modalRef.el : + // Legacy dialog + dialog.$modal[0]; + modalEl.classList.add('o_active_modal'); + // Update body class + document.body.classList.add('modal-open'); + } + + /** + * Set the given displayed dialog as passive and the last added displayed dialog + * as active, then remove it from the displayed list. + * @param {(LegacyDialog|OwlDialog)} dialog + */ + static hide(dialog) { + // Remove given dialog from the list + this.displayed.splice(this.displayed.indexOf(dialog), 1); + // Activate last dialog and update body class + const lastDialog = this.displayed[this.displayed.length - 1]; + if (lastDialog) { + lastDialog.el.focus(); + const modalEl = lastDialog instanceof this ? + // Owl dialog + lastDialog.modalRef.el : + // Legacy dialog + lastDialog.$modal[0]; + modalEl.classList.add('o_active_modal'); + } else { + document.body.classList.remove('modal-open'); + } + } + } + + Dialog.displayed = []; + + Dialog.components = { Portal }; + Dialog.defaultProps = { + backdrop: 'static', + renderFooter: true, + renderHeader: true, + size: 'large', + technical: true, + title: "Odoo", + }; + Dialog.props = { + backdrop: { validate: b => ['static', true, false].includes(b), optional: 1 }, + contentClass: { type: String, optional: 1 }, + fullscreen: { type: Boolean, optional: 1 }, + renderFooter: { type: Boolean, optional: 1 }, + renderHeader: { type: Boolean, optional: 1 }, + size: { validate: s => ['extra-large', 'large', 'medium', 'small'].includes(s), optional: 1 }, + subtitle: { type: String, optional: 1 }, + technical: { type: Boolean, optional: 1 }, + title: { type: String, optional: 1 }, + }; + Dialog.template = 'OwlDialog'; + + return Dialog; +}); diff --git a/addons/web/static/src/scss/modal.scss b/addons/web/static/src/scss/modal.scss index 3463d9e20ac..d82ddec6213 100644 --- a/addons/web/static/src/scss/modal.scss +++ b/addons/web/static/src/scss/modal.scss @@ -54,10 +54,9 @@ footer { > :not(:first-child) { margin-left: .25rem; } > :not(:last-child) { margin-right: .25rem; } - } - - button { - margin-bottom: .5rem; + button { + margin-bottom: .5rem; + } } } } @@ -107,9 +106,28 @@ } } +.modal:not(.o_active_modal) { + z-index: 1030; +} + +.o_dialog { + + > .modal { + display: block; + pointer-events: none; + } + + .modal-content { + pointer-events: auto; + } +} + body.modal-open { // Allow vertical scrolling in modals on iOS -webkit-overflow-scrolling: touch; + .modal { + @include o-scrollbar-overlay; + } } // Temporary fix for modals which are not instantiated thanks to the Dialog diff --git a/addons/web/static/src/scss/utils.scss b/addons/web/static/src/scss/utils.scss index 794b8bea90e..1d410fc7475 100644 --- a/addons/web/static/src/scss/utils.scss +++ b/addons/web/static/src/scss/utils.scss @@ -385,6 +385,13 @@ } } +// Scrollbar doesn't overlap with content +@mixin o-scrollbar-overlay { + overflow-y: auto; + overflow-y: overlay; + -ms-overflow-style: -ms-autohiding-scrollbar; +} + %o-nocontent-init-image { content: ""; display: block; diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index a3fdb09611c..961c11cdc90 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1,6 +1,54 @@ + + + + + + + + +
+ + + + + +
diff --git a/addons/web/static/src/xml/dialog.xml b/addons/web/static/src/xml/dialog.xml index 645e6e2391e..848f5a1e9b6 100644 --- a/addons/web/static/src/xml/dialog.xml +++ b/addons/web/static/src/xml/dialog.xml @@ -3,17 +3,25 @@ -