diff options
Diffstat (limited to 'addons/web/static/src')
| -rw-r--r-- | addons/web/static/src/js/core/dialog.js | 7 | ||||
| -rw-r--r-- | addons/web/static/src/js/core/owl_dialog.js | 259 | ||||
| -rw-r--r-- | addons/web/static/src/scss/modal.scss | 26 | ||||
| -rw-r--r-- | addons/web/static/src/scss/utils.scss | 7 | ||||
| -rw-r--r-- | addons/web/static/src/xml/base.xml | 48 | ||||
| -rw-r--r-- | addons/web/static/src/xml/dialog.xml | 28 |
6 files changed, 361 insertions, 14 deletions
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 @@ <?xml version="1.0" encoding="UTF-8"?> <templates id="template" xml:space="preserve"> +<!-- Owl Templates --> + +<t t-name="DialogButton.tooltip" owl="1"> + <div class="oe_tooltip_string" role="tooltip"> + <div class="tooltip-inner"> + Hit ENTER to <t t-esc="title"/> + </div> + </div> +</t> + +<t t-name="OwlDialog" owl="1"> + <Portal target="'body'"> + <div class="o_dialog" t-on-focus="_onFocus"> + <div t-if="props.backdrop" class="modal-backdrop show" t-on-click="_onBackdropClick"/> + <div role="dialog" class="modal" + tabindex="-1" + t-att-class="{ o_technical_modal: props.technical, o_modal_full: props.fullscreen }" + t-ref="modal" + t-on-close_dialog.stop="_close" + > + <div class="modal-dialog" t-att-class="size"> + <div class="modal-content" t-att-class="props.contentClass"> + <header t-if="props.renderHeader" class="modal-header"> + <h4 class="modal-title"> + <t t-esc="props.title"/> + <span t-if="props.subtitle" class="o_subtitle text-muted small" t-esc="props.subtitle"/> + </h4> + <button type="button" class="close" aria-label="Close" tabindex="-1" t-on-click="_close">×</button> + </header> + <main class="modal-body"> + <t t-slot="default"/> + </main> + <footer t-if="props.renderFooter" class="modal-footer" t-ref="modal-footer"> + <t t-slot="buttons"> + <button class="btn btn-primary" t-on-click.prevent="_close"> + Ok + </button> + </t> + </footer> + </div> + </div> + </div> + </div> + </Portal> +</t> + +<!-- Legacy Templates --> + <div t-name="EmptyComponent"/> <div t-name="Loading" class="o_loading"/> 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 @@ <!-- These templates are accessible in backend and frontend --> -<div role="dialog" t-name="Dialog" t-attf-class="modal#{technical ? ' o_technical_modal' : ''}#{fullscreen ? ' o_modal_full': ''}" tabindex="-1" data-backdrop="static" t-att-id="_.uniqueId('modal_')" aria-hidden="true"> - <div class="modal-dialog"> - <div class="modal-content"> - <header t-if="renderHeader" class="modal-header"> - <h4 class="modal-title"><t t-raw="title"/><span class="o_subtitle text-muted small"><t t-esc="subtitle"/></span></h4> - <button type="button" class="close" data-dismiss="modal" aria-label="Close" tabindex="-1">×</button> - </header> - <main class="modal-body"/> - <footer t-if="renderFooter" class="modal-footer"/> +<t t-name="Dialog"> + <div role="dialog" + t-attf-class="modal o_legacy_dialog #{ technical ? ' o_technical_modal' : '' } #{ fullscreen ? ' o_modal_full': '' }" + tabindex="-1" + data-backdrop="static" + t-att-id="_.uniqueId('modal_')" + aria-hidden="true" + > + <div class="modal-dialog"> + <div class="modal-content"> + <header t-if="renderHeader" class="modal-header"> + <h4 class="modal-title"><t t-raw="title"/><span class="o_subtitle text-muted small" t-esc="subtitle"/></h4> + <button type="button" class="close" data-dismiss="modal" aria-label="Close" tabindex="-1">×</button> + </header> + <main class="modal-body"/> + <footer t-if="renderFooter" class="modal-footer"/> + </div> </div> </div> -</div> +</t> </templates> |
