diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index fd40461b91723..e905aa6c9d5d5 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -1,263 +1,316 @@ -/** @odoo-module **/ - -import { useBus } from "../bus_hook"; -import { useService } from "../service_hook"; -import { scrollTo } from "../utils/scrolling"; -import { ParentClosingMode } from "./dropdown_item"; - -const { Component, core, hooks, useState, QWeb } = owl; -const { useExternalListener, onMounted, onPatched, onWillStart } = hooks; - -export class Dropdown extends Component { - setup() { - this.hotkeyService = useService("hotkey"); - this.hotkeyTokens = []; - this.state = useState({ open: this.props.startOpen, groupIsOpen: this.props.startOpen }); - - 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); - } - useBus(this.ui.bus, "active-element-changed", (activeElement) => { - if (activeElement !== this.myActiveEl) { - this.close(); - } - }); - - onMounted(() => { - Promise.resolve().then(() => { - this.myActiveEl = this.ui.activeElement; - }); - }); - - function autoSubscribeKeynav() { - if (this.state.open) { - this.subscribeKeynav(); - } else { - this.unsubscribeKeynav(); - } - } - - 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 activeItem = this.el.querySelector( - ":scope > ul.o_dropdown_menu > .o_dropdown_item.o_dropdown_active" - ); - if (activeItem) { - activeItem.click(); - } - }, - 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, - }) - ); - } - } - - unsubscribeKeynav() { - this.hotkeyTokens.forEach((tokenId) => this.hotkeyService.unregisterHotkey(tokenId)); - this.hotkeyTokens = []; - } - - close() { - return this.changeStateAndNotify({ open: false, groupIsOpen: false }); - } - - open() { - return this.changeStateAndNotify({ open: true, groupIsOpen: true }); - } - - toggle() { - const toggled = !this.state.open; - return this.changeStateAndNotify({ - open: toggled, - groupIsOpen: toggled, - }); - } - - // --------------------------------------------------------------------------- - // Handlers - // --------------------------------------------------------------------------- - - onItemSelected(ev) { - // Handle parent closing request - const { dropdownClosingRequest } = ev.detail; - const closeAll = dropdownClosingRequest.mode === ParentClosingMode.AllParents; - const closeSelf = - dropdownClosingRequest.isFresh && - dropdownClosingRequest.mode === ParentClosingMode.ClosestParent; - if (!this.props.manualOnly && (closeAll || closeSelf)) { - this.close(); - } - // Mark closing request as started - ev.detail.dropdownClosingRequest.isFresh = false; - } - - /** - * Dropdowns react to each other state changes through this method. - */ - 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 - return; - } - - // Emitted by direct siblings ? - if (args.emitter.el.parentElement === this.el.parentElement) { - // Sync the group status - this.state.groupIsOpen = args.newState.groupIsOpen; - - // Another dropdown is now open ? Close myself without notifying siblings. - if (this.state.open && args.newState.open) { - this.state.open = false; - } - } else { - // Another dropdown is now open ? Close myself and notify the world (i.e. siblings). - if (this.state.open && args.newState.open) { - this.close(); - } - } - } - - onTogglerClick() { - this.toggle(); - } - - onTogglerMouseEnter() { - if (this.state.groupIsOpen && !this.state.open) { - this.open(); - } - } - - /** - * Used to close ourself on outside click. - */ - onWindowClicked(ev) { - // Return if already closed - 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 (!gotClickedInside) { - this.close(); - } - } -} -Dropdown.bus = new core.EventBus(); -Dropdown.props = { - startOpen: { - type: Boolean, - optional: true, - }, - manualOnly: { - type: Boolean, - optional: true, - }, - menuClass: { - type: String, - optional: true, - }, - beforeOpen: { - type: Function, - optional: true, - }, - togglerClass: { - type: String, - optional: true, - }, - hotkey: { - type: String, - optional: true, - }, - title: { - type: String, - optional: true, - }, -}; -Dropdown.template = "web.Dropdown"; - -QWeb.registerComponent("Dropdown", Dropdown); +/** @odoo-module **/ + +import { useBus } from "../bus_hook"; +import { useService } from "../service_hook"; +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.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(); + } + }); + + 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 && !this.state.open) { + // Close when UI active element changes to something different + this.close(); + } + }); + onMounted(() => { + Promise.resolve().then(() => { + this.myActiveEl = this.ui.activeElement; + }); + }); + + // 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")); + } + } + + 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" + ); + if (activeItem) { + activeItem.click(); + } + }, + "escape": this.close.bind(this), + }; + + /** @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)); + } + + // ------------------------------------------------------------------------- + // 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 }); + } + + // ------------------------------------------------------------------------- + // Handlers + // ------------------------------------------------------------------------- + + /** + * Checks if should close on dropdown item selection. + * + * @param {CustomEvent} ev + */ + onItemSelected(ev) { + // Handle parent closing request + const { dropdownClosingRequest } = ev.detail; + const closeAll = dropdownClosingRequest.mode === ParentClosingMode.AllParents; + const closeSelf = + dropdownClosingRequest.isFresh && + dropdownClosingRequest.mode === ParentClosingMode.ClosestParent; + if (!this.props.manualOnly && (closeAll || closeSelf)) { + this.close(); + } + // Mark closing request as started + ev.detail.dropdownClosingRequest.isFresh = false; + } + + /** + * 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 (this.el.contains(args.emitter.el)) { + // Do not listen to events emitted by self or children + return; + } + + // Emitted by direct siblings ? + if (args.emitter.el.parentElement === this.el.parentElement) { + // Sync the group status + this.state.groupIsOpen = args.newState.groupIsOpen; + + // Another dropdown is now open ? Close myself without notifying siblings. + if (this.state.open && args.newState.open) { + this.state.open = false; + } + } else { + // Another dropdown is now open ? Close myself and notify the world (i.e. siblings). + if (this.state.open && args.newState.open) { + this.close(); + } + } + } + + /** + * 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(); + } + } + + /** + * Used to close ourself on outside click. + * + * @param {MouseEvent} ev + */ + onWindowClicked(ev) { + // Return if already closed + if (!this.state.open) { + return; + } + // Return if it's a different ui active element + if (this.ui.activeElement !== this.myActiveEl) { + return; + } + + const gotClickedInside = this.el.contains(ev.target); + if (!gotClickedInside) { + this.close(); + } + } +} +Dropdown.bus = new EventBus(); +Dropdown.props = { + startOpen: { + type: Boolean, + optional: true, + }, + manualOnly: { + type: Boolean, + optional: true, + }, + menuClass: { + type: String, + optional: true, + }, + beforeOpen: { + type: Function, + optional: true, + }, + togglerClass: { + type: String, + optional: true, + }, + hotkey: { + type: String, + optional: true, + }, + title: { + type: String, + optional: true, + }, +}; +Dropdown.template = "web.Dropdown"; + +QWeb.registerComponent("Dropdown", Dropdown); diff --git a/addons/web/static/src/core/dropdown/dropdown_item.js b/addons/web/static/src/core/dropdown/dropdown_item.js index 272256ae89732..0f28aabefb3bb 100644 --- a/addons/web/static/src/core/dropdown/dropdown_item.js +++ b/addons/web/static/src/core/dropdown/dropdown_item.js @@ -1,46 +1,66 @@ -/** @odoo-module **/ - -const { Component, QWeb } = owl; - -export const ParentClosingMode = { - None: "none", - ClosestParent: "closest", - AllParents: "all", -}; - -export class DropdownItem extends Component { - onClick() { - const payload = { - payload: this.props.payload, - dropdownClosingRequest: { - isFresh: true, - mode: this.props.parentClosingMode, - }, - }; - this.trigger("dropdown-item-selected", payload); - } -} -DropdownItem.template = "web.DropdownItem"; -DropdownItem.props = { - payload: { - type: Object, - optional: true, - }, - parentClosingMode: { - type: ParentClosingMode, - optional: true, - }, - hotkey: { - type: String, - optional: true, - }, - title: { - type: String, - optional: true, - }, -}; -DropdownItem.defaultProps = { - parentClosingMode: ParentClosingMode.AllParents, -}; - -QWeb.registerComponent("DropdownItem", DropdownItem); +/** @odoo-module **/ + +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() { + /** @type DropdownItemSelectedEventDetail */ + const detail = { + payload: this.props.payload, + dropdownClosingRequest: { + isFresh: true, + mode: this.props.parentClosingMode, + }, + }; + this.trigger("dropdown-item-selected", detail); + } +} +DropdownItem.template = "web.DropdownItem"; +DropdownItem.props = { + payload: { + type: Object, + optional: true, + }, + parentClosingMode: { + type: ParentClosingMode, + optional: true, + }, + hotkey: { + type: String, + optional: true, + }, + title: { + type: String, + optional: true, + }, +}; +DropdownItem.defaultProps = { + parentClosingMode: ParentClosingMode.AllParents, +}; + +QWeb.registerComponent("DropdownItem", DropdownItem);