diff --git a/addons/web/static/src/core/dialog/dialog.js b/addons/web/static/src/core/dialog/dialog.js index 8e180c32c90..c8fd0dd2a5c 100644 --- a/addons/web/static/src/core/dialog/dialog.js +++ b/addons/web/static/src/core/dialog/dialog.js @@ -1,5 +1,6 @@ /** @odoo-module **/ +import { useHotkey } from "../hotkey_hook"; import { useActiveElement } from "../ui_service"; const { Component, hooks, misc, QWeb } = owl; @@ -10,6 +11,15 @@ export class Dialog extends Component { setup() { this.modalRef = useRef("modal"); useActiveElement("modal"); + useHotkey( + "escape", + () => { + if (!this.modalRef.el.classList.contains("o_inactive_modal")) { + this.close(); + } + }, + { altIsOptional: true } + ); useSubEnv({ inDialog: true }); } diff --git a/addons/web/static/src/core/dropdown/dropdown.js b/addons/web/static/src/core/dropdown/dropdown.js index 02c4d572700..fd40461b917 100644 --- a/addons/web/static/src/core/dropdown/dropdown.js +++ b/addons/web/static/src/core/dropdown/dropdown.js @@ -6,10 +6,12 @@ import { scrollTo } from "../utils/scrolling"; import { ParentClosingMode } from "./dropdown_item"; const { Component, core, hooks, useState, QWeb } = owl; -const { useExternalListener, onMounted, onWillStart } = hooks; +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"); @@ -31,6 +33,16 @@ export class Dropdown extends Component { }); }); + 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(); @@ -82,6 +94,43 @@ export class Dropdown extends Component { } } + 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 }); } diff --git a/addons/web/static/src/core/dropdown/dropdown.xml b/addons/web/static/src/core/dropdown/dropdown.xml index a12ed08d58a..648f02ef00b 100644 --- a/addons/web/static/src/core/dropdown/dropdown.xml +++ b/addons/web/static/src/core/dropdown/dropdown.xml @@ -8,7 +8,8 @@ t-att-class="{ 'o_dropdown_active': state.open }" t-on-click="onTogglerClick" t-on-mouseenter="onTogglerMouseEnter" - t-att-title="props.title"> + t-att-title="props.title" + t-att-data-hotkey="props.hotkey"> diff --git a/addons/web/static/src/core/dropdown/dropdown_item.xml b/addons/web/static/src/core/dropdown/dropdown_item.xml index a78518f326d..e1b9103e3b7 100644 --- a/addons/web/static/src/core/dropdown/dropdown_item.xml +++ b/addons/web/static/src/core/dropdown/dropdown_item.xml @@ -2,7 +2,7 @@ - + diff --git a/addons/web/static/src/webclient/commands/command_category.js b/addons/web/static/src/webclient/commands/command_category.js new file mode 100644 index 00000000000..0c60e85c3df --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_category.js @@ -0,0 +1,11 @@ +/** @odoo-module **/ + +import { _lt } from "../../core/l10n/translation"; +import { registry } from "../../core/registry"; + +const commandCategoryRegistry = registry.category("command_categories"); +commandCategoryRegistry.add("app", { label: _lt("Current App Commands") }, { sequence: 10 }); +commandCategoryRegistry.add("mail", { label: _lt("Discuss") }, { sequence: 20 }); +commandCategoryRegistry.add("actions", { label: _lt("More Actions") }, { sequence: 30 }); +commandCategoryRegistry.add("navbar", { label: _lt("NavBar") }, { sequence: 40 }); +commandCategoryRegistry.add("default", { label: _lt("Other commands") }, { sequence: 100 }); diff --git a/addons/web/static/src/webclient/commands/command_hook.js b/addons/web/static/src/webclient/commands/command_hook.js new file mode 100644 index 00000000000..2b405a3d313 --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_hook.js @@ -0,0 +1,27 @@ +/** @odoo-module **/ + +import { useService } from "../../core/service_hook"; + +const { hooks } = owl; +const { onMounted, onWillUnmount } = hooks; + +/** + * @typedef {import("./command_service").Command} Command + */ + +/** + * This hook will subscribe/unsubscribe the given subscription + * when the caller component will mount/unmount. + * + * @param {Command} command + */ +export function useCommand(command) { + const commandService = useService("command"); + let token; + onMounted(() => { + token = commandService.registerCommand(command); + }); + onWillUnmount(() => { + commandService.unregisterCommand(token); + }); +} diff --git a/addons/web/static/src/webclient/commands/command_palette.js b/addons/web/static/src/webclient/commands/command_palette.js new file mode 100644 index 00000000000..e3b547d9e62 --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_palette.js @@ -0,0 +1,145 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useAutofocus } from "../../core/autofocus_hook"; +import { useHotkey } from "../../core/hotkey_hook"; +import { scrollTo } from "../../core/utils/scrolling"; + +const { Component, hooks } = owl; +const { onPatched, useState } = hooks; + +const commandCategoryRegistry = registry.category("command_categories"); +/** + * @typedef {import("./command_service").Command} Command + */ + +/** + * Util used to filter commands that are within category. + * Note: for the default category, also get all commands having invalid category. + * + * @param {string} categoryName the category key + * @returns an array filter predicate + */ +function commandsWithinCategory(categoryName) { + return (cmd) => { + const inCurrentCategory = categoryName === cmd.category; + const fallbackCategory = + categoryName === "default" && !commandCategoryRegistry.contains(cmd.category); + return inCurrentCategory || fallbackCategory; + }; +} + +export class CommandPalette extends Component { + setup() { + /** + * @type Command[] + */ + this.initialCommands = []; + for (const [key, _] of commandCategoryRegistry.getEntries()) { + const commands = this.props.commands.filter(commandsWithinCategory(key)); + this.initialCommands = this.initialCommands.concat(commands); + } + + /** + * @type {{commands:Command[], selectedCommand: Command}} + */ + this.state = useState({ + commands: this.initialCommands, + selectedCommand: this.initialCommands[0], + }); + + useAutofocus(); + + this.mouseSelectionActive = false; + onPatched(() => { + const index = this.state.commands.indexOf(this.state.selectedCommand); + const listbox = this.el.querySelector(".o_command_palette_listbox"); + const command = listbox.querySelector(`#o_command_${index}`); + scrollTo(command, listbox); + }); + useHotkey( + "Enter", + () => { + this.executeSelectedCommand(); + }, + { altIsOptional: true } + ); + useHotkey( + "ArrowUp", + () => { + this.mouseSelectionActive = false; + const index = this.state.commands.indexOf(this.state.selectedCommand); + if (index > 0) { + this.selectCommand(index - 1); + } + }, + { altIsOptional: true, allowRepeat: true } + ); + useHotkey( + "ArrowDown", + () => { + this.mouseSelectionActive = false; + const index = this.state.commands.indexOf(this.state.selectedCommand); + if (index < this.state.commands.length - 1) { + this.selectCommand(index + 1); + } + }, + { altIsOptional: true, allowRepeat: true } + ); + } + + get categories() { + const categories = []; + for (const [key, value] of commandCategoryRegistry.getEntries()) { + const commands = this.state.commands.filter(commandsWithinCategory(key)); + if (commands.length) { + categories.push({ + ...value, + commands, + }); + } + } + return categories; + } + + selectCommand(index) { + if (index === -1 || index >= this.state.commands.length) { + this.state.selectedCommand = null; + return; + } + this.state.selectedCommand = this.state.commands[index]; + } + + onCommandClicked(index) { + this.selectCommand(index); + this.executeSelectedCommand(); + } + + executeSelectedCommand() { + if (this.state.selectedCommand) { + this.trigger("dialog-closed"); + this.state.selectedCommand.action(); + } + } + + onCommandMouseEnter(index) { + if (this.mouseSelectionActive) { + this.selectCommand(index); + } else { + this.mouseSelectionActive = true; + } + } + + onSearchInput(ev) { + const searchValue = ev.target.value; + const newCommands = []; + for (const command of this.initialCommands) { + if (fuzzy.test(searchValue, command.name)) { + newCommands.push(command); + } + } + this.state.commands = newCommands; + this.selectCommand(this.state.commands.length ? 0 : -1); + } +} +CommandPalette.template = "web.CommandPalette"; diff --git a/addons/web/static/src/webclient/commands/command_palette.scss b/addons/web/static/src/webclient/commands/command_palette.scss new file mode 100644 index 00000000000..b24b731b77a --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_palette.scss @@ -0,0 +1,79 @@ +.o_command_palette { + &.modal-content { + border: none; + border-radius: 3px !important; + overflow: hidden; + } + + > .modal-body { + padding: 0; + overflow: hidden; + } + + &_search { + display: flex; + align-items: center; + justify-content: space-between; + background-color: white; + border-bottom: 1px solid #ccc; + padding: 0.5rem 1em; + + input { + border: none; + padding: 0; + line-height: 1.8em; + } + } + + &_listbox { + max-height: 50vh; + overflow-y: auto; + position: relative; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + &_empty { + padding: 0.7rem 1em; + line-height: 1.8em; + } + + .o_command_category { + padding: 0.5rem 0; + } + + hr { + margin: 0; + } + + .o_command { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.2rem 1em; + position: relative; + line-height: 1.8em; + + &.focused { + background: $o-brand-secondary; + } + + kbd { + background-color: #f4f7f8; + border-radius: 3px; + border: 1px solid #b4b4b4; + $shadowvalue: 0px 1px 1px rgba(0, 0, 0, 0.2), + inset 0px -1px 1px 1px rgba(230, 230, 230, 0.8), + inset 0px 2px 0px 0px rgba(255, 255, 255, 0.8); + color: #4c4c4c; + display: inline-block; + font-family: Consolas, "Liberation Mono", Courier, monospace; + font-size: 0.85em; + padding: 0 0.65rem; + } + } + } +} diff --git a/addons/web/static/src/webclient/commands/command_palette.xml b/addons/web/static/src/webclient/commands/command_palette.xml new file mode 100644 index 00000000000..b56c8aec56e --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_palette.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + No action found + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/web/static/src/webclient/commands/command_palette_dialog.js b/addons/web/static/src/webclient/commands/command_palette_dialog.js new file mode 100644 index 00000000000..8fd9389ff7b --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_palette_dialog.js @@ -0,0 +1,30 @@ +/** @odoo-module **/ + +import { CommandPalette } from "./command_palette"; + +const { Component, hooks } = owl; +const { useExternalListener, useRef } = hooks; + +/** + * @typedef {import("./command_service").Command} Command + */ + +export class CommandPaletteDialog extends Component { + setup() { + this.dialogRef = useRef("dialogRef"); + useExternalListener(window, "mousedown", this.onWindowMouseDown); + } + + /** + * Used to close ourself on outside click. + */ + onWindowMouseDown(ev) { + const element = ev.target.parentElement; + const gotClickedInside = this.dialogRef.comp.modalRef.el.contains(element); + if (!gotClickedInside) { + this.trigger("dialog-closed"); + } + } +} +CommandPaletteDialog.template = "web.CommandPaletteDialog"; +CommandPaletteDialog.components = { CommandPalette }; diff --git a/addons/web/static/src/webclient/commands/command_palette_dialog.xml b/addons/web/static/src/webclient/commands/command_palette_dialog.xml new file mode 100644 index 00000000000..e9f80b03b2d --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_palette_dialog.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/addons/web/static/src/webclient/commands/command_service.js b/addons/web/static/src/webclient/commands/command_service.js new file mode 100644 index 00000000000..a09783edfab --- /dev/null +++ b/addons/web/static/src/webclient/commands/command_service.js @@ -0,0 +1,127 @@ +/** @odoo-module **/ + +import { registry } from "../../core/registry"; +import { getHotkeyToPress } from "../hotkeys/hotkey_service"; +import { CommandPaletteDialog } from "./command_palette_dialog"; + +/** + * @typedef {{ + * name: string, + * action: ()=>void, + * category?: string, + * hotkey?: string, + * hotkeyOptions?: any, + * }} Command + */ + +export const commandService = { + dependencies: ["dialog", "hotkey", "ui"], + start(env, { dialog, hotkey: hotkeyService, ui }) { + const registeredCommands = new Map(); + let nextToken = 0; + let isPaletteOpened = false; + + hotkeyService.registerHotkey("control+k", openPalette, { + altIsOptional: true, + global: true, + }); + + function openPalette() { + if (isPaletteOpened) { + return; + } + + const commands = [...registeredCommands.values()]; + + // Also retrieve all hotkeyables elements + for (const el of ui.getVisibleElements("[data-hotkey]:not(:disabled)")) { + const closest = el.closest("[data-command-category]"); + const category = closest ? closest.dataset.commandCategory : "default"; + + const description = + el.title || + el.placeholder || + (el.innerText && + `${el.innerText.slice(0, 50)}${el.innerText.length > 50 ? "..." : ""}`) || + "no description provided"; + + commands.push({ + name: description, + hotkey: getHotkeyToPress(el.dataset.hotkey), + action: () => { + // AAB: not sure it is enough, we might need to trigger all events that occur when you actually click + el.focus(); + el.click(); + }, + category, + }); + } + + // Open palette dialog + isPaletteOpened = true; + dialog.open( + CommandPaletteDialog, + { commands }, + { + onCloseCallback: () => { + isPaletteOpened = false; + }, + } + ); + } + + /** + * @param {Command} command + * @returns {number} token + */ + function registerCommand(command) { + if (!command.name || !command.action || typeof command.action !== "function") { + throw new Error("A Command must have a name and an action function."); + } + + const registration = Object.assign({}, command, { activeElement: null }); + + if (command.hotkey) { + registration.hotkeyToken = hotkeyService.registerHotkey( + command.hotkey, + command.action, + command.hotkeyOptions + ); + const altIsOptional = command.hotkeyOptions && command.hotkeyOptions.altIsOptional; + registration.hotkey = getHotkeyToPress(command.hotkey, altIsOptional); + } + + const token = nextToken++; + registeredCommands.set(token, registration); + + // Due to the way elements are mounted in the DOM by Owl (bottom-to-top), + // we need to wait the next micro task tick to set the context activate + // element of the subscription. + Promise.resolve().then(() => { + registration.activeElement = ui.activeElement; + }); + + return token; + } + + /** + * Unsubscribes the token corresponding subscription. + * + * @param {number} token + */ + function unregisterCommand(token) { + const cmd = registeredCommands.get(token); + if (cmd && cmd.hotkeyToken >= 0) { + env.services.hotkey.unregisterHotkey(cmd.hotkeyToken); + } + registeredCommands.delete(token); + } + + return { + registerCommand, + unregisterCommand, + }; + }, +}; + +registry.category("services").add("command", commandService); diff --git a/addons/web/static/src/webclient/hotkeys/hotkey.scss b/addons/web/static/src/webclient/hotkeys/hotkey.scss new file mode 100644 index 00000000000..1c852fdcfed --- /dev/null +++ b/addons/web/static/src/webclient/hotkeys/hotkey.scss @@ -0,0 +1,12 @@ +.o_web_hotkey_overlay { + @include o-position-absolute(0, 0); + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.6); + color: #ffffff; + font-size: 1rem; + font-family: $font-family-sans-serif; +} diff --git a/addons/web/static/src/webclient/hotkeys/hotkey_service.js b/addons/web/static/src/webclient/hotkeys/hotkey_service.js new file mode 100644 index 00000000000..59ef533a996 --- /dev/null +++ b/addons/web/static/src/webclient/hotkeys/hotkey_service.js @@ -0,0 +1,317 @@ +/** @odoo-module **/ + +import { isMacOS } from "../../core/browser/feature_detection"; +import { registry } from "../../core/registry"; +import { browser } from "../../core/browser/browser"; + +export function getHotkeyToPress(hotkey, altIsOptional = false) { + let result = hotkey.split("+"); + if (isMacOS()) { + result = result.map((x) => x.replace("control", "command")); + } + if (!altIsOptional) { + result = isMacOS() ? ["control", ...result] : ["alt", ...result]; + } + return result.join("+"); +} + +const ALPHANUM_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789".split(""); +const NAV_KEYS = [ + "arrowleft", + "arrowright", + "arrowup", + "arrowdown", + "pageup", + "pagedown", + "home", + "end", + "backspace", + "enter", + "escape", +]; +const MODIFIERS = new Set(["control", "shift"]); +const AUTHORIZED_KEYS = new Set([...ALPHANUM_KEYS, ...NAV_KEYS]); + +export const hotkeyService = { + dependencies: ["ui"], + start(env, { ui }) { + const registrations = new Map(); + let nextToken = 0; + let overlaysVisible = false; + + browser.addEventListener("keydown", onKeydown); + browser.addEventListener("keyup", removeHotkeyOverlays); + browser.addEventListener("blur", removeHotkeyOverlays); + browser.addEventListener("click", removeHotkeyOverlays); + + /** + * Handler for keydown events. + * Verifies if the keyboard event can be dispatched or not. + * Rules sequence to forbid dispatching : + * - UI is blocked + * - the pressed key is not whitelisted + * + * @param {KeyboardEvent} event + */ + function onKeydown(event) { + if (!event.key) { + // Chrome may trigger incomplete keydown events under certain circumstances. + // E.g. when using browser built-in autocomplete on an input. + // See https://stackoverflow.com/questions/59534586/google-chrome-fires-keydown-event-when-form-autocomplete + return; + } + + const hotkey = getActiveHotkey(event); + + // Do not dispatch if UI is blocked + if (ui.isBlocked) { + return; + } + + // FIXME : this is a temporary hack. It replaces all [accesskey] attrs by [data-hotkey] on all elements. + const elementsWithoutDataHotkey = ui.getVisibleElements( + "[accesskey]:not([data-hotkey])" + ); + for (const el of elementsWithoutDataHotkey) { + el.dataset.hotkey = el.accessKey; + el.removeAttribute("accesskey"); + } + + // Special case: open hotkey overlays + if (isMacOS() ? hotkey === "control" : hotkey === "alt") { + addHotkeyOverlays(); + event.preventDefault(); + return; + } + + // Is the pressed key NOT whitelisted ? + const singleKey = hotkey.split("+").pop(); + if (!AUTHORIZED_KEYS.has(singleKey)) { + return; + } + + // Finally, prepare and dispatch. + const infos = { + hotkey, + _originalEvent: event, + }; + dispatch(infos); + removeHotkeyOverlays(); + } + + /** + * Dispatches an hotkey to all matching registrations and + * clicks on all elements having a data-hotkey attribute matching the hotkey. + * + * @param {{ + * hotkey: string, + * _originalEvent: KeyboardEvent + * }} infos + */ + function dispatch(infos) { + let dispatched = false; + const { hotkey, _originalEvent: event } = infos; + const isAlted = isMacOS() ? event.ctrlKey : event.altKey; + const activeElement = ui.activeElement; + + // Dispatch actual hotkey to all matching registrations + for (const [_, reg] of registrations) { + if (!reg.global && reg.activeElement !== activeElement) { + continue; + } + + if (reg.hotkey !== hotkey) { + continue; + } + + if (!reg.altIsOptional && !isAlted) { + continue; + } + + if (!reg.allowRepeat && event.repeat) { + continue; + } + + reg.callback(); + dispatched = true; + } + + if (!event.repeat && isAlted) { + // Click on all elements having a data-hotkey attribute matching the actual hotkey. + const elems = activeElement.querySelectorAll(`[data-hotkey='${hotkey}' i]`); + for (const el of elems) { + // AAB: not sure it is enough, we might need to trigger all events that occur when you actually click + el.focus(); + el.click(); + dispatched = true; + } + } + + // Prevent default on event if it has been handheld. + if (dispatched) { + event.preventDefault(); + } + } + + /** + * Add the hotkey overlays respecting the ui active element. + */ + function addHotkeyOverlays() { + if (overlaysVisible) { + return; + } + for (const el of env.services.ui.getVisibleElements("[data-hotkey]:not(:disabled)")) { + const hotkey = el.dataset.hotkey; + const overlay = document.createElement("div"); + overlay.className = "o_web_hotkey_overlay"; + overlay.appendChild(document.createTextNode(hotkey.toUpperCase())); + + let overlayParent; + if (el.tagName.toUpperCase() === "INPUT") { + // special case for the search input that has an access key + // defined. We cannot set the overlay on the input itself, + // only on its parent. + overlayParent = el.parentElement; + } else { + overlayParent = el; + } + + if (overlayParent.style.position !== "absolute") { + overlayParent.style.position = "relative"; + } + overlayParent.appendChild(overlay); + } + overlaysVisible = true; + } + + /** + * Remove all the hotkey overlays. + */ + function removeHotkeyOverlays() { + if (!overlaysVisible) { + return; + } + for (const overlay of document.querySelectorAll(".o_web_hotkey_overlay")) { + overlay.remove(); + } + overlaysVisible = false; + } + + /** + * Get the actual hotkey being pressed. + * + * @param {KeyboardEvent} ev + * @returns {string} the active hotkey, in lowercase + */ + function getActiveHotkey(ev) { + const hotkey = []; + + // ------- Modifiers ------- + // Modifiers are pushed in ascending order to the hotkey. + if (isMacOS() ? ev.metaKey : ev.ctrlKey) { + hotkey.push("control"); + } + if (ev.shiftKey) { + hotkey.push("shift"); + } + + // ------- Key ------- + let key = ev.key.toLowerCase(); + // Identify if the user has tapped on the number keys above the text keys. + if (ev.code && ev.code.indexOf("Digit") === 0) { + key = ev.code.slice(-1); + } + // Make sure we do not duplicate a modifier key + if (!hotkey.includes(key)) { + hotkey.push(key); + } + + return hotkey.join("+"); + } + + /** + * Registers a new hotkey. + * + * @param {string} hotkey + * @param {()=>void} callback + * @param {Object} options additional options + * @param {boolean} [options.altIsOptional=false] + * allow registration to perform even without pressing the ALT key + * @param {boolean} [options.allowRepeat=false] + * allow registration to perform multiple times when hotkey is held down + * @param {boolean} [options.global=false] + * allow registration to perform no matter the UI active element + * @returns {number} registration token + */ + function registerHotkey(hotkey, callback, options = {}) { + // Validate some informations + if (!hotkey || hotkey.length === 0) { + throw new Error("You must specify an hotkey when registering a registration."); + } + + if (!callback || typeof callback !== "function") { + throw new Error( + "You must specify a callback function when registering a registration." + ); + } + + /** + * An hotkey must comply to these rules: + * - all parts are whitelisted + * - single key part comes last + * - each part is separated by the dash character: "+" + */ + const keys = hotkey + .toLowerCase() + .split("+") + .filter((k) => !MODIFIERS.has(k)); + if (keys.some((k) => !AUTHORIZED_KEYS.has(k))) { + throw new Error( + `You are trying to subscribe for an hotkey ('${hotkey}') + that contains parts not whitelisted: ${keys.join(", ")}` + ); + } else if (keys.length > 1) { + throw new Error( + `You are trying to subscribe for an hotkey ('${hotkey}') + that contains more than one single key part: ${keys.join("+")}` + ); + } + + // Add registration + const token = nextToken++; + const registration = { + hotkey: hotkey.toLowerCase(), + callback, + activeElement: null, + altIsOptional: options && options.altIsOptional, + allowRepeat: options && options.allowRepeat, + global: options && options.global, + }; + registrations.set(token, registration); + + // Due to the way elements are mounted in the DOM by Owl (bottom-to-top), + // we need to wait the next micro task tick to set the context owner of the registration. + Promise.resolve().then(() => { + registration.activeElement = ui.activeElement; + }); + + return token; + } + + /** + * Unsubscribes the token corresponding registration. + * + * @param {number} token + */ + function unregisterHotkey(token) { + registrations.delete(token); + } + + return { + registerHotkey, + unregisterHotkey, + }; + }, +}; + +registry.category("services").add("hotkey", hotkeyService); diff --git a/addons/web/static/src/webclient/navbar/navbar.xml b/addons/web/static/src/webclient/navbar/navbar.xml index 4e454341779..7e9e1eb1c84 100644 --- a/addons/web/static/src/webclient/navbar/navbar.xml +++ b/addons/web/static/src/webclient/navbar/navbar.xml @@ -33,7 +33,7 @@ - + + + + + + + + - + - + @@ -71,6 +78,9 @@ + + + @@ -94,7 +104,7 @@ - + diff --git a/addons/web/static/tests/webclient/commands/command_service_tests.js b/addons/web/static/tests/webclient/commands/command_service_tests.js new file mode 100644 index 00000000000..50287a60019 --- /dev/null +++ b/addons/web/static/tests/webclient/commands/command_service_tests.js @@ -0,0 +1,307 @@ +/** @odoo-module **/ + +import { dialogService } from "@web/core/dialog/dialog_service"; +import { registry } from "@web/core/registry"; +import { uiService } from "@web/core/ui_service"; +import { useCommand } from "@web/webclient/commands/command_hook"; +import { commandService } from "@web/webclient/commands/command_service"; +import { hotkeyService } from "@web/webclient/hotkeys/hotkey_service"; +import { makeTestEnv } from "../../helpers/mock_env"; +import { click, getFixture, nextTick, triggerHotkey } from "../../helpers/utils"; + +const { Component, mount, tags } = owl; +const { xml } = tags; + +let env; +let target; +let testComponent; +const serviceRegistry = registry.category("services"); +const commandCategoryRegistry = registry.category("command_categories"); + +class TestComponent extends Component {} +TestComponent.components = { + DialogContainer: registry.category("main_components").get("DialogContainer"), +}; +TestComponent.template = xml` + + + + +`; + +QUnit.module("Command", { + async beforeEach() { + serviceRegistry.add("ui", uiService); + serviceRegistry.add("dialog", dialogService); + serviceRegistry.add("hotkey", hotkeyService); + serviceRegistry.add("command", commandService); + + commandCategoryRegistry.add("custom-nolabel", {}); + commandCategoryRegistry.add("custom", { label: "Custom" }); + commandCategoryRegistry.add("default", { label: "Other commands" }); + env = await makeTestEnv(); + target = getFixture(); + }, + afterEach() { + if (testComponent) { + testComponent.destroy(); + } + }, +}); + +QUnit.test("palette dialog can be rendered and closed on outside click", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + + // invoke command palette through hotkey control+k + triggerHotkey("control+k", true); + await nextTick(); + assert.containsOnce(target, ".o_command_palette"); + + // Close on outside click + window.dispatchEvent(new MouseEvent("mousedown")); + await nextTick(); + assert.containsNone(target, ".o_command_palette"); +}); + +QUnit.test("commands evilness 👹", async (assert) => { + const command = env.services.command; + function action() {} + + assert.throws(function () { + command.registerCommand(); + }, /undefined/); + assert.throws(function () { + command.registerCommand(null); + }, /null/); + assert.throws(function () { + command.registerCommand({}); + }, /A Command must have a name and an action function/); + assert.throws(function () { + command.registerCommand({ name: "" }); + }, /A Command must have a name and an action function/); + assert.throws(function () { + command.registerCommand({ action }); + }, /A Command must have a name and an action function/); + assert.throws(function () { + command.registerCommand({ name: "", action }); + }, /A Command must have a name and an action function/); +}); + +QUnit.test("useCommand hook", async (assert) => { + assert.expect(5); + + class MyComponent extends TestComponent { + setup() { + super.setup(); + useCommand({ + name: "Take the throne", + action: () => { + assert.step("Hodor"); + }, + }); + } + } + testComponent = await mount(MyComponent, { env, target }); + + triggerHotkey("control+k", true); + await nextTick(); + assert.containsOnce(target, ".o_command"); + assert.deepEqual(target.querySelector(".o_command").textContent, "Take the throne"); + + await click(target, ".o_command"); + assert.verifySteps(["Hodor"]); + + testComponent.unmount(); + triggerHotkey("control+k", true); + await nextTick(); + assert.containsNone(target, ".o_command"); +}); + +QUnit.test("command with hotkey", async (assert) => { + assert.expect(2); + + const hotkey = "a"; + env.services.command.registerCommand({ + name: "test", + action: () => assert.step(hotkey), + hotkey, + hotkeyOptions: { altIsOptional: true }, + }); + await nextTick(); + + triggerHotkey("a", true); + await nextTick(); + assert.verifySteps([hotkey]); +}); + +QUnit.test("data-hotkey added to command palette", async (assert) => { + assert.expect(8); + + class MyComponent extends Component { + onClick() { + assert.step("Hodor"); + } + } + MyComponent.components = { TestComponent }; + MyComponent.template = xml` + + + + + + `; + testComponent = await mount(MyComponent, { env, target }); + + // Open palette + triggerHotkey("control+k", true); + await nextTick(); + + assert.containsN(target, ".o_command", 2); + assert.deepEqual( + [...target.querySelectorAll(".o_command span:first-child")].map((el) => el.textContent), + ["Aria Stark", "Bran Stark"] + ); + + // Click on first command + await click(target, "#o_command_0"); + assert.containsNone(target, ".o_command_palette", "palette is closed due to command action"); + + // Reopen palette + triggerHotkey("control+k", true); + await nextTick(); + + // Click on second command + assert.notStrictEqual( + document.activeElement, + target.querySelector("input[title='Bran Stark']"), + "input should not have the focus" + ); + await click(target, "#o_command_1"); + assert.containsNone(target, ".o_command_palette", "palette is closed due to command action"); + assert.strictEqual( + document.activeElement, + target.querySelector("input[title='Bran Stark']"), + "input should now have the focus after matching command action has been executed" + ); + + // only step should come from the first command execution + assert.verifySteps(["Hodor"]); +}); + +QUnit.test("can be searched", async (assert) => { + assert.expect(4); + + testComponent = await mount(TestComponent, { env, target }); + + // Register some commands + function action() {} + const names = ["Cersei Lannister", "Jaime Lannister", "Tyrion Lannister", "Tywin Lannister"]; + for (const name of names) { + env.services.command.registerCommand({ name, action }); + } + await nextTick(); + + // Open palette + triggerHotkey("control+k", true); + await nextTick(); + + assert.deepEqual( + target.querySelector(".o_command_palette_search input").value, + "", + "search input is empty" + ); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + names, + "all commands are present" + ); + + // Search something + let search = target.querySelector(".o_command_palette_search input"); + search.value = "jl"; + search.dispatchEvent(new InputEvent("input")); + await nextTick(); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + ["Jaime Lannister"], + "only search matches are present" + ); + + // Clear search input + search.value = ""; + search.dispatchEvent(new InputEvent("input")); + await nextTick(); + + assert.deepEqual( + [...target.querySelectorAll(".o_command")].map((el) => el.textContent), + names, + "all commands are again present" + ); +}); + +QUnit.test("command categories", async (assert) => { + testComponent = await mount(TestComponent, { env, target }); + + // Register some commands + function action() {} + env.services.command.registerCommand({ name: "a", action, category: "custom-nolabel" }); + env.services.command.registerCommand({ name: "b", action, category: "custom" }); + env.services.command.registerCommand({ name: "c", action }); + env.services.command.registerCommand({ name: "d", action, category: "invalid-category" }); + await nextTick(); + + // Open palette + triggerHotkey("control+k", true); + await nextTick(); + + assert.containsN(target, ".o_command_category", 3); + assert.deepEqual( + [...target.querySelectorAll(".o_command_category")].map((el) => el.textContent), + ["a", "b", "cd"] + ); +}); + +QUnit.test("data-command-category", async (assert) => { + assert.expect(3); + + class MyComponent extends Component {} + MyComponent.components = { TestComponent }; + MyComponent.template = xml` + + + + + + + + + + + + `; + testComponent = await mount(MyComponent, { env, target }); + + // Open palette + triggerHotkey("control+k", true); + await nextTick(); + + assert.containsN(target, ".o_command", 4); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(1) .o_command > span:first-child" + ), + ].map((el) => el.textContent), + ["Robert Baratheon", "Joffrey Baratheon"] + ); + assert.deepEqual( + [ + ...target.querySelectorAll( + ".o_command_category:nth-of-type(2) .o_command > span:first-child" + ), + ].map((el) => el.textContent), + ["Aria Stark", "Bran Stark"] + ); +}); diff --git a/addons/web/static/tests/webclient/hotkeys/hotkey_service_tests.js b/addons/web/static/tests/webclient/hotkeys/hotkey_service_tests.js new file mode 100644 index 00000000000..f5c08e50908 --- /dev/null +++ b/addons/web/static/tests/webclient/hotkeys/hotkey_service_tests.js @@ -0,0 +1,497 @@ +/** @odoo-module **/ + +import { browser } from "@web/core/browser/browser"; +import { useHotkey } from "@web/core/hotkey_hook"; +import { registry } from "@web/core/registry"; +import { uiService, useActiveElement } from "@web/core/ui_service"; +import { hotkeyService } from "@web/webclient/hotkeys/hotkey_service"; +import { makeTestEnv } from "../../helpers/mock_env"; +import { getFixture, nextTick, patchWithCleanup, triggerHotkey } from "../../helpers/utils"; + +const { Component, mount, tags } = owl; +const { xml } = tags; +const serviceRegistry = registry.category("services"); + +let env; +let target; + +QUnit.module("Hotkey Service", { + async beforeEach() { + serviceRegistry.add("hotkey", hotkeyService); + serviceRegistry.add("ui", uiService); + env = await makeTestEnv(); + target = getFixture(); + }, +}); + +QUnit.test("register / unregister", async (assert) => { + assert.expect(2); + + const hotkey = env.services.hotkey; + + const key = "q"; + triggerHotkey(key); + await nextTick(); + + let token = hotkey.registerHotkey(key, () => assert.step(key)); + await nextTick(); + + triggerHotkey(key); + await nextTick(); + + hotkey.unregisterHotkey(token); + triggerHotkey(key); + await nextTick(); + + assert.verifySteps([key]); +}); + +QUnit.test("data-hotkey", async (assert) => { + assert.expect(2); + + class MyComponent extends Component { + onClick() { + assert.step("click"); + } + } + MyComponent.template = xml` + + + + `; + + const key = "b"; + triggerHotkey(key); + await nextTick(); + + const comp = await mount(MyComponent, { env, target }); + + triggerHotkey(key); + await nextTick(); + + comp.unmount(); + + triggerHotkey(key); + await nextTick(); + + assert.verifySteps(["click"]); + comp.destroy(); +}); + +QUnit.test("hook", async (assert) => { + const key = "q"; + class TestComponent extends Component { + setup() { + useHotkey(key, () => assert.step(key)); + } + } + TestComponent.template = xml``; + + triggerHotkey(key); + await nextTick(); + + const comp = await mount(TestComponent, { env, target }); + + triggerHotkey(key); + await nextTick(); + + comp.unmount(); + + triggerHotkey(key); + await nextTick(); + + assert.verifySteps([key]); + comp.destroy(); +}); + +QUnit.test("non-MacOS usability", async (assert) => { + assert.expect(6); + + patchWithCleanup(browser, { + navigator: { + platform: "OdooOS", + }, + }); + + const hotkey = env.services.hotkey; + const key = "q"; + + // On non-MacOS, ALT is NOT replaced by CONTROL key + let token = hotkey.registerHotkey(key, () => assert.step(key), { altIsOptional: false }); + await nextTick(); + + let keydown = new KeyboardEvent("keydown", { key, altKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([key]); + + keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([]); + + hotkey.unregisterHotkey(token); + + // On non-MacOS, CONTROL is NOT replaced by COMMAND key (= metaKey) + token = hotkey.registerHotkey(`control+${key}`, () => assert.step(`control+${key}`), { + altIsOptional: true, + }); + await nextTick(); + + keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([`control+${key}`]); + + keydown = new KeyboardEvent("keydown", { key, metaKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([]); + + hotkey.unregisterHotkey(token); +}); + +QUnit.test("MacOS usability", async (assert) => { + assert.expect(6); + + patchWithCleanup(browser, { + navigator: { + platform: "Mac", + }, + }); + + const hotkey = env.services.hotkey; + const key = "q"; + + // On MacOS, ALT is replaced by CONTROL key + let token = hotkey.registerHotkey(key, () => assert.step(key), { altIsOptional: false }); + await nextTick(); + + let keydown = new KeyboardEvent("keydown", { key, altKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([]); + + keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([key]); + + hotkey.unregisterHotkey(token); + + // On MacOS, CONTROL is replaced by COMMAND key (= metaKey) + token = hotkey.registerHotkey(`control+${key}`, () => assert.step(`control+${key}`), { + altIsOptional: true, + }); + await nextTick(); + + keydown = new KeyboardEvent("keydown", { key, ctrlKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([]); + + keydown = new KeyboardEvent("keydown", { key, metaKey: true }); + window.dispatchEvent(keydown); + await nextTick(); + assert.verifySteps([`control+${key}`]); + + hotkey.unregisterHotkey(token); +}); + +QUnit.test("alt is optional parameter", async (assert) => { + const altIsOptionalKey = "a"; + const altIsRequiredKey = "b"; + const defaultBehaviourKey = "c"; + class TestComponent extends Component { + setup() { + useHotkey(altIsOptionalKey, () => assert.step(altIsOptionalKey), { + altIsOptional: true, + }); + useHotkey(altIsRequiredKey, () => assert.step(altIsRequiredKey), { + altIsOptional: false, + }); + useHotkey(defaultBehaviourKey, () => assert.step(defaultBehaviourKey)); + } + } + TestComponent.template = xml``; + + const comp = await mount(TestComponent, { env, target }); + + // Dispatch keys without ALT + triggerHotkey(altIsOptionalKey, true); + triggerHotkey(altIsRequiredKey, true); + triggerHotkey(defaultBehaviourKey, true); + await nextTick(); + assert.verifySteps([altIsOptionalKey]); + + // Dispatch keys with ALT + triggerHotkey(altIsOptionalKey, false); + triggerHotkey(altIsRequiredKey, false); + triggerHotkey(defaultBehaviourKey, false); + await nextTick(); + assert.verifySteps([altIsOptionalKey, altIsRequiredKey, defaultBehaviourKey]); + + comp.destroy(); +}); + +QUnit.test("[data-hotkey] alt is required", async (assert) => { + const key = "a"; + class TestComponent extends Component { + onClick() { + assert.step(key); + } + } + TestComponent.template = xml``; + + const comp = await mount(TestComponent, { env, target }); + + triggerHotkey(key); + await nextTick(); + assert.verifySteps([key]); + + triggerHotkey(key, true); + await nextTick(); + assert.verifySteps([]); + + comp.destroy(); +}); + +QUnit.test("registration allows repeat if specified", async (assert) => { + assert.expect(6); + + const allowRepeatKey = "a"; + const disallowRepeatKey = "b"; + const defaultBehaviourKey = "c"; + + env.services.hotkey.registerHotkey(allowRepeatKey, () => assert.step(allowRepeatKey), { + allowRepeat: true, + }); + env.services.hotkey.registerHotkey(disallowRepeatKey, () => assert.step(disallowRepeatKey), { + allowRepeat: false, + }); + env.services.hotkey.registerHotkey(defaultBehaviourKey, () => assert.step(defaultBehaviourKey)); + await nextTick(); + + // Dispatch the three keys without repeat: + triggerHotkey(allowRepeatKey); + triggerHotkey(disallowRepeatKey); + triggerHotkey(defaultBehaviourKey); + await nextTick(); + + assert.verifySteps([allowRepeatKey, disallowRepeatKey, defaultBehaviourKey]); + + // Dispatch the three keys with repeat: + triggerHotkey(allowRepeatKey, false, { repeat: true }); + triggerHotkey(disallowRepeatKey, false, { repeat: true }); + triggerHotkey(defaultBehaviourKey, false, { repeat: true }); + await nextTick(); + + assert.verifySteps([allowRepeatKey]); +}); + +QUnit.test("[data-hotkey] never allow repeat", async (assert) => { + assert.expect(3); + const key = "a"; + class TestComponent extends Component { + onClick() { + assert.step(key); + } + } + TestComponent.template = xml``; + + const comp = await mount(TestComponent, { env, target }); + + triggerHotkey(key); + await nextTick(); + assert.verifySteps([key]); + + triggerHotkey(key, false, { repeat: true }); + await nextTick(); + assert.verifySteps([]); + + comp.destroy(); +}); + +QUnit.test("hotkeys evil 👹", async (assert) => { + const hotkey = env.services.hotkey; + + assert.throws(function () { + hotkey.registerHotkey(); + }, /must specify an hotkey/); + assert.throws(function () { + hotkey.registerHotkey(null); + }, /must specify an hotkey/); + + function callback() {} + assert.throws(function () { + hotkey.registerHotkey(null, callback); + }, /must specify an hotkey/); + assert.throws(function () { + hotkey.registerHotkey(""); + }, /must specify an hotkey/); + assert.throws(function () { + hotkey.registerHotkey("crap", callback); + }, /not whitelisted/); + assert.throws(function () { + hotkey.registerHotkey("ctrl+o", callback); + }, /not whitelisted/); + assert.throws(function () { + hotkey.registerHotkey("Control+o"); + }, /specify a callback/); + assert.throws(function () { + hotkey.registerHotkey("Control+o+d", callback); + }, /more than one single key part/); +}); + +QUnit.test("component can register many hotkeys", async (assert) => { + assert.expect(8); + + class MyComponent extends Component { + setup() { + for (const hotkey of ["a", "b", "c"]) { + useHotkey(hotkey, () => assert.step(`callback:${hotkey}`)); + } + for (const hotkey of ["d", "e", "f"]) { + useHotkey(hotkey, () => assert.step(`callback2:${hotkey}`)); + } + } + onClick() { + assert.step("click"); + } + } + MyComponent.template = xml` + + + + `; + + const comp = await mount(MyComponent, { env, target }); + triggerHotkey("a"); + triggerHotkey("b"); + triggerHotkey("c"); + triggerHotkey("d"); + triggerHotkey("e"); + triggerHotkey("f"); + await nextTick(); + + assert.verifySteps([ + "callback:a", + "callback:b", + "click", + "callback:c", + "callback2:d", + "callback2:e", + "callback2:f", + ]); + comp.destroy(); +}); + +QUnit.test("many components can register same hotkeys", async (assert) => { + assert.expect(1); + + const result = []; + const hotkeys = ["a", "b", "c"]; + + class MyComponent1 extends Component { + setup() { + for (const hotkey of hotkeys) { + useHotkey(hotkey, () => result.push(`comp1:${hotkey}`)); + } + } + onClick() { + result.push("comp1:click"); + } + } + MyComponent1.template = xml` + + + + `; + + class MyComponent2 extends Component { + setup() { + for (const hotkey of hotkeys) { + useHotkey(hotkey, () => result.push(`comp2:${hotkey}`)); + } + } + onClick() { + result.push("comp2:click"); + } + } + MyComponent2.template = xml` + + + + `; + + const comp1 = await mount(MyComponent1, { env, target }); + const comp2 = await mount(MyComponent2, { env, target }); + triggerHotkey("a"); + triggerHotkey("b"); + triggerHotkey("c"); + await nextTick(); + + assert.deepEqual(result.sort(), [ + "comp1:a", + "comp1:b", + "comp1:c", + "comp1:click", + "comp2:a", + "comp2:b", + "comp2:c", + "comp2:click", + ]); + comp1.destroy(); + comp2.destroy(); +}); + +QUnit.test("registrations and elements belong to the correct UI owner", async (assert) => { + assert.expect(7); + class MyComponent1 extends Component { + setup() { + useHotkey("a", () => assert.step("MyComponent1 subscription")); + } + onClick() { + assert.step("MyComponent1 [data-hotkey]"); + } + } + MyComponent1.template = xml``; + + class MyComponent2 extends Component { + setup() { + useHotkey("a", () => assert.step("MyComponent2 subscription")); + useActiveElement(); + } + onClick() { + assert.step("MyComponent2 [data-hotkey]"); + } + } + MyComponent2.template = xml``; + + const comp1 = await mount(MyComponent1, { env, target }); + triggerHotkey("a"); + triggerHotkey("b"); + await nextTick(); + + const comp2 = await mount(MyComponent2, { env, target }); + triggerHotkey("a"); + triggerHotkey("b"); + await nextTick(); + + comp2.unmount(); + triggerHotkey("a"); + triggerHotkey("b"); + await nextTick(); + + assert.verifySteps([ + "MyComponent1 subscription", + "MyComponent1 [data-hotkey]", + "MyComponent2 subscription", + "MyComponent2 [data-hotkey]", + "MyComponent1 subscription", + "MyComponent1 [data-hotkey]", + ]); + + comp1.destroy(); + comp2.destroy(); +});