[REF] web: simplify dropdown components
This commit adds jsdoc and typings to the dropdown components and also rearranges a bit the code so it is cleaner and simpler. Most part of the diff is about the keynav feature. closes odoo-dev/odoo#802 Signed-off-by: Géry Debongnie (ged) <ged@openerp.com>
This commit is contained in:
@@ -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<DropdownState>} 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<void>}
|
||||
*/
|
||||
close() {
|
||||
return this.changeStateAndNotify({ open: false, groupIsOpen: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the dropdown.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
open() {
|
||||
return this.changeStateAndNotify({ open: true, groupIsOpen: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the dropdown open state.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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<import("./dropdown_item").DropdownItemSelectedEventDetail>} 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,
|
||||
|
||||
@@ -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<DropdownItemSelectedEventDetail>} 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";
|
||||
|
||||
Reference in New Issue
Block a user