diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index fd40461b917..781a2ceed92 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -6,105 +6,89 @@ import { scrollTo } from "../utils/scrolling"; import { ParentClosingMode } from "./dropdown_item"; const { Component, core, hooks, useState, QWeb } = owl; +const { EventBus } = core; const { useExternalListener, onMounted, onPatched, onWillStart } = hooks; +/** + * @typedef DropdownState + * @property {boolean} open + * @property {boolean} groupIsOpen + */ + +/** + * @typedef DropdownStateChangedPayload + * @property {Dropdown} emitter + * @property {DropdownState} newState + */ + +/** + * @extends Component + */ export class Dropdown extends Component { setup() { - this.hotkeyService = useService("hotkey"); - this.hotkeyTokens = []; - this.state = useState({ open: this.props.startOpen, groupIsOpen: this.props.startOpen }); + this.state = useState({ + open: this.props.startOpen, + groupIsOpen: this.props.startOpen, + }); + + onWillStart(() => { + if ((this.state.open || this.state.groupIsOpen) && this.props.beforeOpen) { + return this.props.beforeOpen(); + } + }); - this.ui = useService("ui"); if (!this.props.manualOnly) { // Close on outside click listener useExternalListener(window, "click", this.onWindowClicked); // Listen to all dropdowns state changes useBus(Dropdown.bus, "state-changed", this.onDropdownStateChanged); } + + // Set up UI active element related behavior --------------------------- + this.ui = useService("ui"); useBus(this.ui.bus, "active-element-changed", (activeElement) => { - if (activeElement !== this.myActiveEl) { + if (activeElement !== this.myActiveEl && !this.state.open) { + // Close when UI active element changes to something different this.close(); } }); - onMounted(() => { Promise.resolve().then(() => { this.myActiveEl = this.ui.activeElement; }); }); - function autoSubscribeKeynav() { - if (this.state.open) { - this.subscribeKeynav(); - } else { - this.unsubscribeKeynav(); - } + // Set up key navigation ----------------------------------------------- + this.hotkeyService = useService("hotkey"); + this.hotkeyTokens = []; + + const nextActiveIndexFns = { + "FIRST": () => 0, + "LAST": (items) => items.length - 1, + "NEXT": (items, prevActiveIndex) => Math.min(prevActiveIndex + 1, items.length - 1), + "PREV": (_, prevActiveIndex) => Math.max(0, prevActiveIndex - 1), + }; + + /** @type {(direction: "FIRST"|"LAST"|"NEXT"|"PREV") => Function} */ + function activeItemSetter(direction) { + return function () { + const items = [...this.el.querySelectorAll(":scope > ul.o_dropdown_menu > .o_dropdown_item")]; + const prevActiveIndex = items.findIndex((item) => + [...item.classList].includes("o_dropdown_active") + ); + const nextActiveIndex = nextActiveIndexFns[direction](items, prevActiveIndex); + items.forEach((item) => item.classList.remove("o_dropdown_active")); + items[nextActiveIndex].classList.add("o_dropdown_active"); + scrollTo(items[nextActiveIndex], this.el.querySelector(".o_dropdown_menu")); + }; } - onMounted(autoSubscribeKeynav.bind(this)); - onPatched(autoSubscribeKeynav.bind(this)); - onWillStart(() => { - if ((this.state.open || this.state.groupIsOpen) && this.props.beforeOpen) { - return this.props.beforeOpen(); - } - }); - } - - // --------------------------------------------------------------------------- - // Private - // --------------------------------------------------------------------------- - - async changeStateAndNotify(stateSlice) { - if ((stateSlice.open || stateSlice.groupIsOpen) && this.props.beforeOpen) { - await this.props.beforeOpen(); - } - // Update the state - Object.assign(this.state, stateSlice); - // Notify over the bus - Dropdown.bus.trigger("state-changed", { - emitter: this, - newState: { ...this.state }, - }); - } - - /** - * @param {"PREV"|"NEXT"|"FIRST"|"LAST"} direction - */ - setActiveItem(direction) { - const items = [ - ...this.el.querySelectorAll(":scope > ul.o_dropdown_menu > .o_dropdown_item"), - ]; - const prevActiveIndex = items.findIndex((item) => - [...item.classList].includes("o_dropdown_active") - ); - const nextActiveIndex = - direction === "NEXT" - ? Math.min(prevActiveIndex + 1, items.length - 1) - : direction === "PREV" - ? Math.max(0, prevActiveIndex - 1) - : direction === "LAST" - ? items.length - 1 - : direction === "FIRST" - ? 0 - : undefined; - if (nextActiveIndex !== undefined) { - items.forEach((item) => item.classList.remove("o_dropdown_active")); - items[nextActiveIndex].classList.add("o_dropdown_active"); - scrollTo(items[nextActiveIndex], this.el.querySelector(".o_dropdown_menu")); - } - } - - subscribeKeynav() { - if (this.hotkeyTokens.length) { - return; - } - - const subs = { - arrowup: () => this.setActiveItem("PREV"), - arrowdown: () => this.setActiveItem("NEXT"), - "shift+arrowup": () => this.setActiveItem("FIRST"), - "shift+arrowdown": () => this.setActiveItem("LAST"), - enter: () => { + const hotkeyCallbacks = { + "arrowdown": activeItemSetter("NEXT").bind(this), + "arrowup": activeItemSetter("PREV").bind(this), + "shift+arrowdown": activeItemSetter("LAST").bind(this), + "shift+arrowup": activeItemSetter("FIRST").bind(this), + "enter": () => { const activeItem = this.el.querySelector( ":scope > ul.o_dropdown_menu > .o_dropdown_item.o_dropdown_active" ); @@ -112,45 +96,104 @@ export class Dropdown extends Component { activeItem.click(); } }, - escape: this.close.bind(this), + "escape": this.close.bind(this), }; - this.hotkeyTokens = []; - for (const [hotkey, callback] of Object.entries(subs)) { - this.hotkeyTokens.push( - this.hotkeyService.registerHotkey(hotkey, callback, { - altIsOptional: true, - allowRepeat: true, - }) - ); + /** @this {Dropdown} */ + function autoSubscribeKeynav() { + if (this.state.open) { + // Subscribe keynav + if (this.hotkeyTokens.length) { + // Keynav already subscribed + return; + } + for (const [hotkey, callback] of Object.entries(hotkeyCallbacks)) { + this.hotkeyTokens.push( + this.hotkeyService.registerHotkey(hotkey, callback, { + altIsOptional: true, + allowRepeat: true, + }) + ); + } + } else { + // Unsubscribe keynav + for (const token of this.hotkeyTokens) { + this.hotkeyService.unregisterHotkey(token); + } + this.hotkeyTokens = []; + } } + + onMounted(autoSubscribeKeynav.bind(this)); + onPatched(autoSubscribeKeynav.bind(this)); } - unsubscribeKeynav() { - this.hotkeyTokens.forEach((tokenId) => this.hotkeyService.unregisterHotkey(tokenId)); - this.hotkeyTokens = []; + // ------------------------------------------------------------------------- + // Private + // ------------------------------------------------------------------------- + + /** + * Changes the dropdown state and notifies over the Dropdown bus. + * + * All state changes must trigger on the bus, except when reacting to + * another dropdown state change. + * + * @see onDropdownStateChanged() + * + * @param {Partial} stateSlice + */ + async changeStateAndNotify(stateSlice) { + if ((stateSlice.open || stateSlice.groupIsOpen) && this.props.beforeOpen) { + await this.props.beforeOpen(); + } + // Update the state + Object.assign(this.state, stateSlice); + // Notify over the bus + /** @type DropdownStateChangedPayload */ + const stateChangedPayload = { + emitter: this, + newState: { ...this.state }, + }; + Dropdown.bus.trigger("state-changed", stateChangedPayload); } + /** + * Closes the dropdown. + * + * @returns {Promise} + */ close() { return this.changeStateAndNotify({ open: false, groupIsOpen: false }); } + /** + * Opens the dropdown. + * + * @returns {Promise} + */ open() { return this.changeStateAndNotify({ open: true, groupIsOpen: true }); } + /** + * Toggles the dropdown open state. + * + * @returns {Promise} + */ toggle() { const toggled = !this.state.open; - return this.changeStateAndNotify({ - open: toggled, - groupIsOpen: toggled, - }); + return this.changeStateAndNotify({ open: toggled, groupIsOpen: toggled }); } - // --------------------------------------------------------------------------- + // ------------------------------------------------------------------------- // Handlers - // --------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + /** + * Checks if should close on dropdown item selection. + * + * @param {CustomEvent} ev + */ onItemSelected(ev) { // Handle parent closing request const { dropdownClosingRequest } = ev.detail; @@ -167,15 +210,17 @@ export class Dropdown extends Component { /** * Dropdowns react to each other state changes through this method. + * + * All state changes must trigger on the bus, except when reacting to + * another dropdown state change. + * + * @see changeStateAndNotify() + * + * @param {DropdownStateChangedPayload} args */ onDropdownStateChanged(args) { - if (args.emitter.el === this.el) { - // Do not listen to my own events - return; - } - if (this.el.contains(args.emitter.el)) { - // Do not listen to events emitted by children + // Do not listen to events emitted by self or children return; } @@ -196,10 +241,17 @@ export class Dropdown extends Component { } } + /** + * Toggles the dropdown on its toggler click. + */ onTogglerClick() { this.toggle(); } + /** + * Opens the dropdown the mous enters its toggler. + * NB: only if its siblings dropdown group is opened. + */ onTogglerMouseEnter() { if (this.state.groupIsOpen && !this.state.open) { this.open(); @@ -208,26 +260,26 @@ export class Dropdown extends Component { /** * Used to close ourself on outside click. + * + * @param {MouseEvent} ev */ onWindowClicked(ev) { // Return if already closed - if (!this.state.open) return; + if (!this.state.open) { + return; + } // Return if it's a different ui active element - if (this.ui.activeElement !== this.myActiveEl) return; - - let element = ev.target; - let gotClickedInside = false; - do { - element = element.parentElement && element.parentElement.closest(".o_dropdown"); - gotClickedInside = element === this.el; - } while (element && element.parentElement && !gotClickedInside); + if (this.ui.activeElement !== this.myActiveEl) { + return; + } + const gotClickedInside = this.el.contains(ev.target); if (!gotClickedInside) { this.close(); } } } -Dropdown.bus = new core.EventBus(); +Dropdown.bus = new EventBus(); Dropdown.props = { startOpen: { type: Boolean, diff --git a/addons/web/static/src/core/dropdown/dropdown_item.js b/addons/web/static/src/core/dropdown/dropdown_item.js index 272256ae897..c51361aec57 100644 --- a/addons/web/static/src/core/dropdown/dropdown_item.js +++ b/addons/web/static/src/core/dropdown/dropdown_item.js @@ -2,22 +2,42 @@ const { Component, QWeb } = owl; +/** + * @enum {string} + */ export const ParentClosingMode = { None: "none", ClosestParent: "closest", AllParents: "all", }; +/** + * @typedef DropdownItemSelectedEventDetail + * @property {*} payload + * @property {Object} dropdownClosingRequest + * @property {boolean} dropdownClosingRequest.isFresh + * @property {ParentClosingMode} dropdownClosingRequest.mode + * + * @typedef {CustomEvent} DropdownItemSelectedEvent + */ + +/** + * @extends Component + */ export class DropdownItem extends Component { + /** + * Triggers a custom DropdownItemSelectedEvent + */ onClick() { - const payload = { + /** @type DropdownItemSelectedEventDetail */ + const detail = { payload: this.props.payload, dropdownClosingRequest: { isFresh: true, mode: this.props.parentClosingMode, }, }; - this.trigger("dropdown-item-selected", payload); + this.trigger("dropdown-item-selected", detail); } } DropdownItem.template = "web.DropdownItem";