summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
Diffstat (limited to 'addons/web/static/src')
-rw-r--r--addons/web/static/src/js/core/dialog.js7
-rw-r--r--addons/web/static/src/js/core/owl_dialog.js259
-rw-r--r--addons/web/static/src/scss/modal.scss26
-rw-r--r--addons/web/static/src/scss/utils.scss7
-rw-r--r--addons/web/static/src/xml/base.xml48
-rw-r--r--addons/web/static/src/xml/dialog.xml28
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>