Files
Benoit Socias 871b2ecaea [FIX] web, website: further neutralize BS dropdown when editing submenu
Since [1] when the mega menu were first introduced, to make their
edition possible, the bootstrap dropdown behavior was neutralized by
removing the `data-[bs-]toggle` attribute.

In [2] when Bootstrap was upgraded to version 5.1.3, the
`dataApiKeydownHandler` event handler that is called when up, down or
escape are pressed raises an error if `data-bs-toggle` cannot be found
on a previous sibling of the dropdown. This makes the approach chosen
in [1] incomplete.

This commit avoids this issue by temporarily adding a class that
is excluded from the event handler selector.
The patch must unfortunately be applied within bootstrap itself because
the event handler is registered right after the method is defined and
we have no way to access the registered event handlers afterwards.
This makes it impossible to patch the called method from outside, nor
its associated selector.

We cannot simply update the selector that was already patched by [3]
because it is also used by other methods.

A test is included which should mitigate the risk of accidentally losing
this patch upon bootstrap upgrades.

Steps to reproduce:
- Create a mega menu.
- Edit the page.
- Open the mega menu.
- Click inside the mega menu content to have a cursor selection.
- Press the arrow up key, the arrow down key or the escape key.

=> An error dialog was displayed.

The same issue happened with a nested menu instead of a mega menu.

[1]: https://github.com/odoo/odoo/commit/1345702258adbfbee0d780dc22e552395e6d1df7
[2]: https://github.com/odoo/odoo/commit/971e5a91aab96d36129a823e03f1f9f1b1293968
[3]: https://github.com/odoo/odoo/commit/daca8fe4e3da4a5ad5fabf3730496a3919af8abc

task-3614926
opw-3741670

closes odoo/odoo#155019

X-original-commit: 48f0c4203ceb5e770fed6193bd6382690b1b62fe
Signed-off-by: Quentin Smetz (qsm) <qsm@odoo.com>
Signed-off-by: Benoit Socias (bso) <bso@odoo.com>
2024-02-22 16:24:01 +00:00

689 lines
22 KiB
JavaScript

/*!
* Bootstrap dropdown.js v5.1.3 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('@popperjs/core'), require('./dom/event-handler.js'), require('./dom/manipulator.js'), require('./dom/selector-engine.js'), require('./base-component.js')) :
typeof define === 'function' && define.amd ? define(['@popperjs/core', './dom/event-handler', './dom/manipulator', './dom/selector-engine', './base-component'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Dropdown = factory(global.Popper, global.EventHandler, global.Manipulator, global.SelectorEngine, global.Base));
})(this, (function (Popper, EventHandler, Manipulator, SelectorEngine, BaseComponent) { 'use strict';
const _interopDefaultLegacy = e => e && typeof e === 'object' && 'default' in e ? e : { default: e };
function _interopNamespace(e) {
if (e && e.__esModule) return e;
const n = Object.create(null);
if (e) {
for (const k in e) {
if (k !== 'default') {
const d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: () => e[k]
});
}
}
}
n.default = e;
return Object.freeze(n);
}
const Popper__namespace = /*#__PURE__*/_interopNamespace(Popper);
const EventHandler__default = /*#__PURE__*/_interopDefaultLegacy(EventHandler);
const Manipulator__default = /*#__PURE__*/_interopDefaultLegacy(Manipulator);
const SelectorEngine__default = /*#__PURE__*/_interopDefaultLegacy(SelectorEngine);
const BaseComponent__default = /*#__PURE__*/_interopDefaultLegacy(BaseComponent);
/**
* --------------------------------------------------------------------------
* Bootstrap (v5.1.3): util/index.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
const toType = obj => {
if (obj === null || obj === undefined) {
return `${obj}`;
}
return {}.toString.call(obj).match(/\s([a-z]+)/i)[1].toLowerCase();
};
const getSelector = element => {
let selector = element.getAttribute('data-bs-target');
if (!selector || selector === '#') {
let hrefAttr = element.getAttribute('href'); // The only valid content that could double as a selector are IDs or classes,
// so everything starting with `#` or `.`. If a "real" URL is used as the selector,
// `document.querySelector` will rightfully complain it is invalid.
// See https://github.com/twbs/bootstrap/issues/32273
if (!hrefAttr || !hrefAttr.includes('#') && !hrefAttr.startsWith('.')) {
return null;
} // Just in case some CMS puts out a full URL with the anchor appended
if (hrefAttr.includes('#') && !hrefAttr.startsWith('#')) {
hrefAttr = `#${hrefAttr.split('#')[1]}`;
}
selector = hrefAttr && hrefAttr !== '#' ? hrefAttr.trim() : null;
}
return selector;
};
const getElementFromSelector = element => {
const selector = getSelector(element);
return selector ? document.querySelector(selector) : null;
};
const isElement = obj => {
if (!obj || typeof obj !== 'object') {
return false;
}
if (typeof obj.jquery !== 'undefined') {
obj = obj[0];
}
return typeof obj.nodeType !== 'undefined';
};
const getElement = obj => {
if (isElement(obj)) {
// it's a jQuery object or a node element
return obj.jquery ? obj[0] : obj;
}
if (typeof obj === 'string' && obj.length > 0) {
return document.querySelector(obj);
}
return null;
};
const typeCheckConfig = (componentName, config, configTypes) => {
Object.keys(configTypes).forEach(property => {
const expectedTypes = configTypes[property];
const value = config[property];
const valueType = value && isElement(value) ? 'element' : toType(value);
if (!new RegExp(expectedTypes).test(valueType)) {
throw new TypeError(`${componentName.toUpperCase()}: Option "${property}" provided type "${valueType}" but expected type "${expectedTypes}".`);
}
});
};
const isVisible = element => {
if (!isElement(element) || element.getClientRects().length === 0) {
return false;
}
return getComputedStyle(element).getPropertyValue('visibility') === 'visible';
};
const isDisabled = element => {
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
return true;
}
if (element.classList.contains('disabled')) {
return true;
}
if (typeof element.disabled !== 'undefined') {
return element.disabled;
}
return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false';
};
const noop = () => {};
const getjQuery = () => {
const {
jQuery
} = window;
if (jQuery && !document.body.hasAttribute('data-bs-no-jquery')) {
return jQuery;
}
return null;
};
const DOMContentLoadedCallbacks = [];
const onDOMContentLoaded = callback => {
if (document.readyState === 'loading') {
// add listener on the first call when the document is in loading state
if (!DOMContentLoadedCallbacks.length) {
document.addEventListener('DOMContentLoaded', () => {
DOMContentLoadedCallbacks.forEach(callback => callback());
});
}
DOMContentLoadedCallbacks.push(callback);
} else {
callback();
}
};
const isRTL = () => document.documentElement.dir === 'rtl';
const defineJQueryPlugin = plugin => {
onDOMContentLoaded(() => {
const $ = getjQuery();
/* istanbul ignore if */
if ($) {
const name = plugin.NAME;
const JQUERY_NO_CONFLICT = $.fn[name];
$.fn[name] = plugin.jQueryInterface;
$.fn[name].Constructor = plugin;
$.fn[name].noConflict = () => {
$.fn[name] = JQUERY_NO_CONFLICT;
return plugin.jQueryInterface;
};
}
});
};
/**
* Return the previous/next element of a list.
*
* @param {array} list The list of elements
* @param activeElement The active element
* @param shouldGetNext Choose to get next or previous element
* @param isCycleAllowed
* @return {Element|elem} The proper element
*/
const getNextActiveElement = (list, activeElement, shouldGetNext, isCycleAllowed) => {
let index = list.indexOf(activeElement); // if the element does not exist in the list return an element depending on the direction and if cycle is allowed
if (index === -1) {
return list[!shouldGetNext && isCycleAllowed ? list.length - 1 : 0];
}
const listLength = list.length;
index += shouldGetNext ? 1 : -1;
if (isCycleAllowed) {
index = (index + listLength) % listLength;
}
return list[Math.max(0, Math.min(index, listLength - 1))];
};
/**
* --------------------------------------------------------------------------
* Bootstrap (v5.1.3): dropdown.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* ------------------------------------------------------------------------
* Constants
* ------------------------------------------------------------------------
*/
const NAME = 'dropdown';
const DATA_KEY = 'bs.dropdown';
const EVENT_KEY = `.${DATA_KEY}`;
const DATA_API_KEY = '.data-api';
const ESCAPE_KEY = 'Escape';
const SPACE_KEY = 'Space';
const TAB_KEY = 'Tab';
const ARROW_UP_KEY = 'ArrowUp';
const ARROW_DOWN_KEY = 'ArrowDown';
const RIGHT_MOUSE_BUTTON = 2; // MouseEvent.button value for the secondary button, usually the right button
const REGEXP_KEYDOWN = new RegExp(`${ARROW_UP_KEY}|${ARROW_DOWN_KEY}|${ESCAPE_KEY}`);
const EVENT_HIDE = `hide${EVENT_KEY}`;
const EVENT_HIDDEN = `hidden${EVENT_KEY}`;
const EVENT_SHOW = `show${EVENT_KEY}`;
const EVENT_SHOWN = `shown${EVENT_KEY}`;
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`;
const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`;
const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}`;
const CLASS_NAME_SHOW = 'show';
const CLASS_NAME_DROPUP = 'dropup';
const CLASS_NAME_DROPEND = 'dropend';
const CLASS_NAME_DROPSTART = 'dropstart';
const CLASS_NAME_NAVBAR = 'navbar';
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]';
const SELECTOR_MENU = '.dropdown-menu:not(.o-dropdown--menu)';
const SELECTOR_MENU_NOT_SUB = '.dropdown-menu:not(.o-dropdown--menu):not(.o_wysiwyg_submenu)';
const SELECTOR_NAVBAR_NAV = '.navbar-nav';
const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)';
const PLACEMENT_TOP = isRTL() ? 'top-end' : 'top-start';
const PLACEMENT_TOPEND = isRTL() ? 'top-start' : 'top-end';
const PLACEMENT_BOTTOM = isRTL() ? 'bottom-end' : 'bottom-start';
const PLACEMENT_BOTTOMEND = isRTL() ? 'bottom-start' : 'bottom-end';
const PLACEMENT_RIGHT = isRTL() ? 'left-start' : 'right-start';
const PLACEMENT_LEFT = isRTL() ? 'right-start' : 'left-start';
const Default = {
offset: [0, 2],
boundary: 'clippingParents',
reference: 'toggle',
display: 'dynamic',
popperConfig: null,
autoClose: true
};
const DefaultType = {
offset: '(array|string|function)',
boundary: '(string|element)',
reference: '(string|element|object)',
display: 'string',
popperConfig: '(null|object|function)',
autoClose: '(boolean|string)'
};
/**
* ------------------------------------------------------------------------
* Class Definition
* ------------------------------------------------------------------------
*/
class Dropdown extends BaseComponent__default.default {
constructor(element, config) {
super(element);
this._popper = null;
this._config = this._getConfig(config);
this._menu = this._getMenuElement();
this._inNavbar = this._detectNavbar();
} // Getters
static get Default() {
return Default;
}
static get DefaultType() {
return DefaultType;
}
static get NAME() {
return NAME;
} // Public
toggle() {
return this._isShown() ? this.hide() : this.show();
}
show() {
if (isDisabled(this._element) || this._isShown(this._menu)) {
return;
}
const relatedTarget = {
relatedTarget: this._element
};
const showEvent = EventHandler__default.default.trigger(this._element, EVENT_SHOW, relatedTarget);
if (showEvent.defaultPrevented) {
return;
}
const parent = Dropdown.getParentFromElement(this._element); // Totally disable Popper for Dropdowns in Navbar
if (this._inNavbar) {
Manipulator__default.default.setDataAttribute(this._menu, 'popper', 'none');
} else {
this._createPopper(parent);
} // If this is a touch-enabled device we add extra
// empty mouseover listeners to the body's immediate children;
// only needed because of broken event delegation on iOS
// https://www.quirksmode.org/blog/archives/2014/02/mouse_event_bub.html
if ('ontouchstart' in document.documentElement && !parent.closest(SELECTOR_NAVBAR_NAV)) {
[].concat(...document.body.children).forEach(elem => EventHandler__default.default.on(elem, 'mouseover', noop));
}
this._element.focus();
this._element.setAttribute('aria-expanded', true);
this._menu.classList.add(CLASS_NAME_SHOW);
this._element.classList.add(CLASS_NAME_SHOW);
EventHandler__default.default.trigger(this._element, EVENT_SHOWN, relatedTarget);
}
hide() {
if (isDisabled(this._element) || !this._isShown(this._menu)) {
return;
}
const relatedTarget = {
relatedTarget: this._element
};
this._completeHide(relatedTarget);
}
dispose() {
if (this._popper) {
this._popper.destroy();
}
super.dispose();
}
update() {
this._inNavbar = this._detectNavbar();
if (this._popper) {
this._popper.update();
}
} // Private
_completeHide(relatedTarget) {
const hideEvent = EventHandler__default.default.trigger(this._element, EVENT_HIDE, relatedTarget);
if (hideEvent.defaultPrevented) {
return;
} // If this is a touch-enabled device we remove the extra
// empty mouseover listeners we added for iOS support
if ('ontouchstart' in document.documentElement) {
[].concat(...document.body.children).forEach(elem => EventHandler__default.default.off(elem, 'mouseover', noop));
}
if (this._popper) {
this._popper.destroy();
}
this._menu.classList.remove(CLASS_NAME_SHOW);
this._element.classList.remove(CLASS_NAME_SHOW);
this._element.setAttribute('aria-expanded', 'false');
Manipulator__default.default.removeDataAttribute(this._menu, 'popper');
EventHandler__default.default.trigger(this._element, EVENT_HIDDEN, relatedTarget);
}
_getConfig(config) {
config = { ...this.constructor.Default,
...Manipulator__default.default.getDataAttributes(this._element),
...config
};
typeCheckConfig(NAME, config, this.constructor.DefaultType);
if (typeof config.reference === 'object' && !isElement(config.reference) && typeof config.reference.getBoundingClientRect !== 'function') {
// Popper virtual elements require a getBoundingClientRect method
throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`);
}
return config;
}
_createPopper(parent) {
if (typeof Popper__namespace === 'undefined') {
throw new TypeError('Bootstrap\'s dropdowns require Popper (https://popper.js.org)');
}
let referenceElement = this._element;
if (this._config.reference === 'parent') {
referenceElement = parent;
} else if (isElement(this._config.reference)) {
referenceElement = getElement(this._config.reference);
} else if (typeof this._config.reference === 'object') {
referenceElement = this._config.reference;
}
const popperConfig = this._getPopperConfig();
const isDisplayStatic = popperConfig.modifiers.find(modifier => modifier.name === 'applyStyles' && modifier.enabled === false);
this._popper = Popper__namespace.createPopper(referenceElement, this._menu, popperConfig);
if (isDisplayStatic) {
Manipulator__default.default.setDataAttribute(this._menu, 'popper', 'static');
}
}
_isShown(element = this._element) {
return element.classList.contains(CLASS_NAME_SHOW);
}
_getMenuElement() {
return SelectorEngine__default.default.next(this._element, SELECTOR_MENU)[0];
}
_getPlacement() {
const parentDropdown = this._element.parentNode;
if (parentDropdown.classList.contains(CLASS_NAME_DROPEND)) {
return PLACEMENT_RIGHT;
}
if (parentDropdown.classList.contains(CLASS_NAME_DROPSTART)) {
return PLACEMENT_LEFT;
} // We need to trim the value because custom properties can also include spaces
const isEnd = getComputedStyle(this._menu).getPropertyValue('--bs-position').trim() === 'end';
if (parentDropdown.classList.contains(CLASS_NAME_DROPUP)) {
return isEnd ? PLACEMENT_TOPEND : PLACEMENT_TOP;
}
return isEnd ? PLACEMENT_BOTTOMEND : PLACEMENT_BOTTOM;
}
_detectNavbar() {
return this._element.closest(`.${CLASS_NAME_NAVBAR}`) !== null;
}
_getOffset() {
const {
offset
} = this._config;
if (typeof offset === 'string') {
return offset.split(',').map(val => Number.parseInt(val, 10));
}
if (typeof offset === 'function') {
return popperData => offset(popperData, this._element);
}
return offset;
}
_getPopperConfig() {
const defaultBsPopperConfig = {
placement: this._getPlacement(),
modifiers: [{
name: 'preventOverflow',
options: {
boundary: this._config.boundary
}
}, {
name: 'offset',
options: {
offset: this._getOffset()
}
}]
}; // Disable Popper if we have a static display
if (this._config.display === 'static') {
defaultBsPopperConfig.modifiers = [{
name: 'applyStyles',
enabled: false
}];
}
return { ...defaultBsPopperConfig,
...(typeof this._config.popperConfig === 'function' ? this._config.popperConfig(defaultBsPopperConfig) : this._config.popperConfig)
};
}
_selectMenuItem({
key,
target
}) {
const items = SelectorEngine__default.default.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(isVisible);
if (!items.length) {
return;
} // if target isn't included in items (e.g. when expanding the dropdown)
// allow cycling to get the last item in case key equals ARROW_UP_KEY
getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus();
} // Static
static jQueryInterface(config) {
return this.each(function () {
const data = Dropdown.getOrCreateInstance(this, config);
if (typeof config !== 'string') {
return;
}
if (typeof data[config] === 'undefined') {
throw new TypeError(`No method named "${config}"`);
}
data[config]();
});
}
static clearMenus(event) {
if (event && (event.button === RIGHT_MOUSE_BUTTON || event.type === 'keyup' && event.key !== TAB_KEY)) {
return;
}
const toggles = SelectorEngine__default.default.find(SELECTOR_DATA_TOGGLE);
for (let i = 0, len = toggles.length; i < len; i++) {
const context = Dropdown.getInstance(toggles[i]);
if (!context || context._config.autoClose === false) {
continue;
}
if (!context._isShown()) {
continue;
}
const relatedTarget = {
relatedTarget: context._element
};
if (event) {
const composedPath = event.composedPath();
const isMenuTarget = composedPath.includes(context._menu);
if (composedPath.includes(context._element) || context._config.autoClose === 'inside' && !isMenuTarget || context._config.autoClose === 'outside' && isMenuTarget) {
continue;
} // Tab navigation through the dropdown menu or events from contained inputs shouldn't close the menu
if (context._menu.contains(event.target) && (event.type === 'keyup' && event.key === TAB_KEY || /input|select|option|textarea|form/i.test(event.target.tagName))) {
continue;
}
if (event.type === 'click') {
relatedTarget.clickEvent = event;
}
}
context._completeHide(relatedTarget);
}
}
static getParentFromElement(element) {
return getElementFromSelector(element) || element.parentNode;
}
static dataApiKeydownHandler(event) {
// If not input/textarea:
// - And not a key in REGEXP_KEYDOWN => not a dropdown command
// If input/textarea:
// - If space key => not a dropdown command
// - If key is other than escape
// - If key is not up or down => not a dropdown command
// - If trigger inside the menu => not a dropdown command
if (/input|textarea/i.test(event.target.tagName) ? event.key === SPACE_KEY || event.key !== ESCAPE_KEY && (event.key !== ARROW_DOWN_KEY && event.key !== ARROW_UP_KEY || event.target.closest(SELECTOR_MENU)) : !REGEXP_KEYDOWN.test(event.key)) {
return;
}
const isActive = this.classList.contains(CLASS_NAME_SHOW);
if (!isActive && event.key === ESCAPE_KEY) {
return;
}
event.preventDefault();
event.stopPropagation();
if (isDisabled(this)) {
return;
}
const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ? this : SelectorEngine__default.default.prev(this, SELECTOR_DATA_TOGGLE)[0];
const instance = Dropdown.getOrCreateInstance(getToggleButton);
if (event.key === ESCAPE_KEY) {
instance.hide();
return;
}
if (event.key === ARROW_UP_KEY || event.key === ARROW_DOWN_KEY) {
if (!isActive) {
instance.show();
}
instance._selectMenuItem(event);
return;
}
if (!isActive || event.key === SPACE_KEY) {
Dropdown.clearMenus();
}
}
}
/**
* ------------------------------------------------------------------------
* Data Api implementation
* ------------------------------------------------------------------------
*/
EventHandler__default.default.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Dropdown.dataApiKeydownHandler);
EventHandler__default.default.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU_NOT_SUB, Dropdown.dataApiKeydownHandler);
EventHandler__default.default.on(document, EVENT_CLICK_DATA_API, Dropdown.clearMenus);
EventHandler__default.default.on(document, EVENT_KEYUP_DATA_API, Dropdown.clearMenus);
EventHandler__default.default.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) {
event.preventDefault();
Dropdown.getOrCreateInstance(this).toggle();
});
/**
* ------------------------------------------------------------------------
* jQuery
* ------------------------------------------------------------------------
* add .Dropdown to jQuery only if jQuery is present
*/
defineJQueryPlugin(Dropdown);
return Dropdown;
}));
//# sourceMappingURL=dropdown.js.map