From 34bbdd88c0bc354b76b254bb78c4eb786f3d08c0 Mon Sep 17 00:00:00 2001 From: Julien Mougenot Date: Fri, 22 Nov 2019 11:36:52 +0000 Subject: [PATCH] [ADD] web: Owl Dialog New Owl component: Dialog. It does not rely on Bootstrap but behaves the same and does not conflict with legacy dialogs. To call this component, you need to define it in the XML parent template instead of calling it in the JS file, and put a flag in the parent state to toggle the dialog. Old dialog file has also been slightly updated to ensure compatibility between both versions. This commit also updates a list view test which did not wait for click events to properly trigger and would cause the new dialog system to crash. --- addons/web/static/src/js/core/dialog.js | 7 + addons/web/static/src/js/core/owl_dialog.js | 259 ++++++++++++++++++ addons/web/static/src/scss/modal.scss | 26 +- addons/web/static/src/scss/utils.scss | 7 + addons/web/static/src/xml/base.xml | 48 ++++ addons/web/static/src/xml/dialog.xml | 28 +- .../web/static/tests/core/owl_dialog_tests.js | 250 +++++++++++++++++ addons/web/static/tests/views/list_tests.js | 8 +- addons/web/views/webclient_templates.xml | 2 + 9 files changed, 617 insertions(+), 18 deletions(-) create mode 100644 addons/web/static/src/js/core/owl_dialog.js create mode 100644 addons/web/static/tests/core/owl_dialog_tests.js 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 @@ -