[IMP] web: add hotkey and command services
Co-authored-by: Bruno Boi <boi@odoo.com> Co-authored-by: Francois (fge) <fge@odoo.com>
This commit is contained in:
committed by
Aaron Bohy
co-authored by
Francois
parent
0573acae23
commit
3a4f1c31de
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
<span>
|
||||
<t t-slot="default" />
|
||||
</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.DropdownItem" owl="1">
|
||||
<li class="o_dropdown_item" t-on-click="onClick" t-att-title="props.title">
|
||||
<li class="o_dropdown_item" t-on-click="onClick" t-att-title="props.title" t-att-data-hotkey="props.hotkey">
|
||||
<span>
|
||||
<t t-slot="default" />
|
||||
</span>
|
||||
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CommandPalette" owl="1">
|
||||
<div>
|
||||
<div class="o_command_palette_search">
|
||||
<input type="text" class="o_command_palette_search" t-on-input="onSearchInput" autofocus="" placeholder="Search for an action..." />
|
||||
<i title="Search for an action..." role="img" aria-label="Search for an action..." class="fa fa-search"></i>
|
||||
</div>
|
||||
|
||||
<div class="o_command_palette_listbox">
|
||||
|
||||
<div t-if="!categories.length" class="o_command_palette_listbox_empty">No action found</div>
|
||||
|
||||
<t t-foreach="categories" t-as="category">
|
||||
|
||||
<div class="o_command_category">
|
||||
|
||||
<t t-foreach="category.commands" t-as="command">
|
||||
<t t-set="commandIndex" t-value="state.commands.indexOf(command)" />
|
||||
<span t-attf-id="o_command_{{commandIndex}}" class="o_command" t-att-class="{ focused: state.selectedCommand === command }" t-on-click="onCommandClicked(commandIndex)" t-on-mouseenter="onCommandMouseEnter(commandIndex)">
|
||||
<span t-esc="command.name" />
|
||||
<span t-if="command.hotkey">
|
||||
<t t-foreach="command.hotkey.toUpperCase().split('+')" t-as="key">
|
||||
<kbd t-esc="key" />
|
||||
<span t-if="!key_last"> + </span>
|
||||
</t>
|
||||
</span>
|
||||
</span>
|
||||
</t>
|
||||
|
||||
</div>
|
||||
|
||||
<hr t-if="!category_last" />
|
||||
|
||||
</t>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.CommandPaletteDialog" owl="1">
|
||||
<Dialog contentClass="'o_command_palette'" renderHeader="false" renderFooter="false" t-ref="dialogRef">
|
||||
<CommandPalette t-props="props"/>
|
||||
</Dialog>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -33,7 +33,7 @@
|
||||
</t>
|
||||
|
||||
<t t-name="web.NavBar.AppsMenu" owl="1">
|
||||
<Dropdown title="'Home Menu'" class="o_navbar_apps_menu">
|
||||
<Dropdown hotkey="'h'" title="'Home Menu'" class="o_navbar_apps_menu">
|
||||
<i class="fa fa-th-large" />
|
||||
<t t-set-slot="menu">
|
||||
<MenuItem
|
||||
@@ -52,13 +52,20 @@
|
||||
<t t-foreach="sections" t-as="section" t-key="section.id">
|
||||
<t t-set="sectionsVisibleCount" t-value="(sections.length - currentAppSectionsExtra.length)" />
|
||||
|
||||
<t t-if="section_index lt Math.min(10, sectionsVisibleCount)">
|
||||
<t t-set="hotkey" t-value="((section_index + 1) % 10).toString()"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-set="hotkey" t-value="undefined"/>
|
||||
</t>
|
||||
|
||||
<t t-if="!section.childrenTree.length">
|
||||
<MenuItem title="section.name" payload="section">
|
||||
<MenuItem title="section.name" payload="section" hotkey="hotkey">
|
||||
<span t-esc="section.name" t-att-data-section="section.id" />
|
||||
</MenuItem>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<MenuDropdown title="section.name" xmlid="section.xmlid">
|
||||
<MenuDropdown hotkey="hotkey" title="section.name" xmlid="section.xmlid">
|
||||
<span t-esc="section.name" t-att-data-section="section.id" />
|
||||
<t t-set-slot="menu">
|
||||
<t t-call="web.NavBar.SectionsMenu.Dropdown.MenuSlot">
|
||||
@@ -71,6 +78,9 @@
|
||||
|
||||
<t t-call="web.NavBar.SectionsMenu.MoreDropdown">
|
||||
<t t-set="sections" t-value="currentAppSectionsExtra" />
|
||||
<t t-if="sectionsVisibleCount lt 10">
|
||||
<t t-set="hotkey" t-value="(sectionsVisibleCount + 1 % 10).toString()"/>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</ul>
|
||||
@@ -94,7 +104,7 @@
|
||||
</t>
|
||||
|
||||
<t t-name="web.NavBar.SectionsMenu.MoreDropdown" owl="1">
|
||||
<Dropdown class="o_menu_sections_more d-none" title="'More Menu'">
|
||||
<Dropdown class="o_menu_sections_more d-none" title="'More Menu'" hotkey="hotkey">
|
||||
<i class="fa fa-plus"/>
|
||||
<t t-set-slot="menu">
|
||||
<t t-foreach="sections" t-as="section" t-key="section.id">
|
||||
|
||||
@@ -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`
|
||||
<div>
|
||||
<div class="o_dialog_container"/>
|
||||
<DialogContainer/>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`
|
||||
<div>
|
||||
<button title="Aria Stark" data-hotkey="a" t-on-click="onClick" />
|
||||
<input title="Bran Stark" type="text" data-hotkey="b" />
|
||||
<TestComponent />
|
||||
</div>
|
||||
`;
|
||||
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`
|
||||
<div>
|
||||
<div>
|
||||
<button title="Aria Stark" data-hotkey="a" />
|
||||
<button title="Bran Stark" data-hotkey="b" />
|
||||
</div>
|
||||
<div data-command-category="custom">
|
||||
<button title="Robert Baratheon" data-hotkey="r" />
|
||||
<button title="Joffrey Baratheon" data-hotkey="j" />
|
||||
</div>
|
||||
<TestComponent />
|
||||
</div>
|
||||
`;
|
||||
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"]
|
||||
);
|
||||
});
|
||||
@@ -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`
|
||||
<div>
|
||||
<button t-on-click="onClick" data-hotkey="b" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`<div/>`;
|
||||
|
||||
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`<div/>`;
|
||||
|
||||
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`<div><button t-on-click="onClick" data-hotkey="${key}"/></div>`;
|
||||
|
||||
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`<div><button t-on-click="onClick" data-hotkey="${key}"/></div>`;
|
||||
|
||||
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`
|
||||
<div>
|
||||
<button t-on-click="onClick" data-hotkey="b" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`
|
||||
<div>
|
||||
<button t-on-click="onClick" data-hotkey="b" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
class MyComponent2 extends Component {
|
||||
setup() {
|
||||
for (const hotkey of hotkeys) {
|
||||
useHotkey(hotkey, () => result.push(`comp2:${hotkey}`));
|
||||
}
|
||||
}
|
||||
onClick() {
|
||||
result.push("comp2:click");
|
||||
}
|
||||
}
|
||||
MyComponent2.template = xml`
|
||||
<div>
|
||||
<button t-on-click="onClick" data-hotkey="b" />
|
||||
</div>
|
||||
`;
|
||||
|
||||
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`<div><button data-hotkey="b" t-on-click="onClick()"/></div>`;
|
||||
|
||||
class MyComponent2 extends Component {
|
||||
setup() {
|
||||
useHotkey("a", () => assert.step("MyComponent2 subscription"));
|
||||
useActiveElement();
|
||||
}
|
||||
onClick() {
|
||||
assert.step("MyComponent2 [data-hotkey]");
|
||||
}
|
||||
}
|
||||
MyComponent2.template = xml`<div><button data-hotkey="b" t-on-click="onClick()"/></div>`;
|
||||
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user