[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:
Bruno Boi
2021-06-18 21:31:29 +02:00
committed by Aaron Bohy
parent f05ab3cb43
commit 79d0923df0
2 changed files with 184 additions and 112 deletions
+162 -110
View File
@@ -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";