[IMP] web: add hotkey service options

This commit adds two options to the hotkey service to improve its
usability in some cases:
- `area` which is a function that returns an HTMLElement that restricts
  the operating area for an hotkey registration
- `validate` which is a function that returns a boolean that tells if
  the hotkey registration's callback should be called or not

Some typings adaptations were made in:
- @web/core/commands/command_palette
- @web/core/commands/command_service
- @web/core/utils/ui

Part-of: odoo/odoo#92475
This commit is contained in:
Bruno Boi
2022-06-30 09:29:43 +02:00
committed by Aaron Bohy
parent 37271102be
commit aa97c19814
6 changed files with 366 additions and 106 deletions
@@ -45,7 +45,7 @@ const FUZZY_NAMESPACES = ["default"];
/**
* @typedef {{
* configByNamespace?: {[namespace]: NamespaceConfig};
* configByNamespace?: {[namespace: string]: NamespaceConfig};
* FooterComponent?: Component;
* providers: Provider[];
* searchValue?: string;
@@ -18,11 +18,8 @@ const { Component, xml } = owl;
*/
/**
* @typedef {{
* activeElement?: HTMLElement;
* @typedef {import("../hotkeys/hotkey_service").HotkeyOptions & {
* category?: string;
* global?: boolean;
* hotkey?: string;
* }} CommandOptions
*/
@@ -9,15 +9,8 @@ const { useEffect } = owl;
* when the caller component will mount/unmount.
*
* @param {string} hotkey
* @param {()=>void} callback
* @param {Object} options additional options
* @param {boolean} [options.allowRepeat=false]
* allow registration to perform multiple times when hotkey is held down
* @param {boolean} [options.bypassEditableProtection=false]
* if true the hotkey service will call this registration
* even if an editable element is focused
* @param {boolean} [options.global=false]
* allow registration to perform no matter the UI active element
* @param {import("./hotkey_service").HotkeyCallback} callback
* @param {import("./hotkey_service").HotkeyOptions} [options] additional options
*/
export function useHotkey(hotkey, callback, options = {}) {
const hotkeyService = useService("hotkey");
@@ -5,6 +5,29 @@ import { registry } from "../registry";
import { browser } from "../browser/browser";
import { getVisibleElements } from "../utils/ui";
/**
* @typedef {(context: { area: HTMLElement, target: EventTarget }) => void} HotkeyCallback
*
* @typedef {Object} HotkeyOptions
* @property {boolean} [allowRepeat]
* allow registration to perform multiple times when hotkey is held down
* @property {boolean} [bypassEditableProtection]
* if true the hotkey service will call this registration
* even if an editable element is focused
* @property {boolean} [global]
* allow registration to perform no matter the UI active element
* @property {() => HTMLElement} [area]
* adds a restricted operating area for this hotkey
* @property {(target: EventTarget) => boolean} [validate]
* adds a validation before calling the hotkey registration's callback
*
* @typedef {HotkeyOptions & {
* hotkey: string,
* callback: HotkeyCallback,
* activeElement: HTMLElement,
* }} HotkeyRegistration
*/
const ALPHANUM_KEYS = "abcdefghijklmnopqrstuvwxyz0123456789".split("");
const NAV_KEYS = [
"arrowleft",
@@ -19,16 +42,56 @@ const NAV_KEYS = [
"enter",
"tab",
"delete",
"space",
];
const MODIFIERS = ["alt", "control", "shift"];
const AUTHORIZED_KEYS = [...ALPHANUM_KEYS, ...NAV_KEYS, "escape"];
/**
* Get the actual hotkey being pressed.
*
* @param {KeyboardEvent} ev
* @returns {string} the active hotkey, in lowercase
*/
export function getActiveHotkey(ev) {
const hotkey = [];
// ------- Modifiers -------
// Modifiers are pushed in ascending order to the hotkey.
if (isMacOS() ? ev.ctrlKey : ev.altKey) {
hotkey.push("alt");
}
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);
}
// Prefer physical keys for non-latin keyboard layout.
if (!AUTHORIZED_KEYS.includes(key) && ev.code && ev.code.indexOf("Key") === 0) {
key = ev.code.slice(-1).toLowerCase();
}
// Make sure we do not duplicate a modifier key
if (!MODIFIERS.includes(key)) {
hotkey.push(key);
}
return hotkey.join("+");
}
export const hotkeyService = {
dependencies: ["ui"],
// Be aware that all odoo hotkeys are designed with this modifier in mind,
// so changing the overlay modifier may conflict with some shortcuts.
overlayModifier: "alt",
start(env, { ui }) {
/** @type {Map<number, HotkeyRegistration>} */
const registrations = new Map();
let nextToken = 0;
let overlaysVisible = false;
@@ -111,6 +174,7 @@ export const hotkeyService = {
activeElement,
hotkey,
isRepeated: event.repeat,
target: event.target,
shouldProtectEditable,
};
const dispatched = dispatch(infos);
@@ -140,33 +204,60 @@ export const hotkeyService = {
* activeElement: HTMLElement,
* hotkey: string,
* isRepeated: boolean,
* target: EventTarget,
* shouldProtectEditable: boolean,
* }} infos
* @returns {boolean} true if has been dispatched
*/
function dispatch(infos) {
const { activeElement, hotkey, isRepeated, shouldProtectEditable } = infos;
const { activeElement, hotkey, isRepeated, target, shouldProtectEditable } = infos;
// Prepare registrations and the common filter
const reversedRegistrations = Array.from(registrations.values()).reverse();
const domRegistrations = getDomRegistrations(hotkey, activeElement);
const allRegistrations = reversedRegistrations.concat(domRegistrations);
// Dispatch actual hotkey to first matching registration
const match = allRegistrations.find(
// Find all candidates
const candidates = allRegistrations.filter(
(reg) =>
reg.hotkey === hotkey &&
(reg.allowRepeat || !isRepeated) &&
(reg.bypassEditableProtection || !shouldProtectEditable) &&
(reg.global || reg.activeElement === activeElement)
(reg.global || reg.activeElement === activeElement) &&
(!reg.validate || reg.validate(target)) &&
(!reg.area ||
(target instanceof Node && reg.area() && reg.area().contains(target)))
);
if (match) {
match.callback();
// First candidate
let winner = candidates.shift();
if (winner && winner.area) {
// If there is an area, find the closest one
for (const candidate of candidates) {
if (candidate.area() && winner.area().contains(candidate.area())) {
winner = candidate;
}
}
}
// Dispatch actual hotkey to the matching registration
if (winner) {
winner.callback({
area: winner.area && winner.area(),
target,
});
return true;
}
return false;
}
/**
* Get a list of registrations from the [data-hotkey] defined in the DOM
*
* @param {string} hotkey
* @param {HTMLElement} activeElement
* @returns {HotkeyRegistration[]}
*/
function getDomRegistrations(hotkey, activeElement) {
const overlayModParts = hotkeyService.overlayModifier.split("+");
if (!overlayModParts.every((el) => hotkey.includes(el))) {
@@ -194,7 +285,7 @@ export const hotkeyService = {
/**
* Add the hotkey overlays respecting the ui active element.
* @param {Element} activeElement
* @param {HTMLElement} activeElement
*/
function addHotkeyOverlays(activeElement) {
for (const el of getVisibleElements(activeElement, "[data-hotkey]:not(:disabled)")) {
@@ -247,57 +338,12 @@ export const hotkeyService = {
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.ctrlKey : ev.altKey) {
hotkey.push("alt");
}
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);
}
// Prefer physical keys for non-latin keyboard layout.
if (!AUTHORIZED_KEYS.includes(key) && ev.code && ev.code.indexOf("Key") === 0) {
key = ev.code.slice(-1).toLowerCase();
}
// Make sure we do not duplicate a modifier key
if (!MODIFIERS.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.allowRepeat=false]
* allow registration to perform multiple times when hotkey is held down
* @param {boolean} [options.bypassEditableProtection=false]
* if true the hotkey service will call this registration
* even if an editable element is focused
* @param {boolean} [options.global=false]
* allow registration to perform no matter the UI active element
* @param {HotkeyCallback} callback
* @param {HotkeyOptions} [options]
* @returns {number} registration token
*/
function registerHotkey(hotkey, callback, options = {}) {
@@ -336,6 +382,7 @@ export const hotkeyService = {
// Add registration
const token = nextToken++;
/** @type {HotkeyRegistration} */
const registration = {
hotkey: hotkey.toLowerCase(),
callback,
@@ -343,8 +390,9 @@ export const hotkeyService = {
allowRepeat: options && options.allowRepeat,
bypassEditableProtection: options && options.bypassEditableProtection,
global: options && options.global,
area: options && options.area,
validate: options && options.validate,
};
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.
@@ -352,6 +400,7 @@ export const hotkeyService = {
registration.activeElement = ui.activeElement;
});
registrations.set(token, registration);
return token;
}
@@ -367,11 +416,8 @@ export const hotkeyService = {
return {
/**
* @param {string} hotkey
* @param {() => void} callback
* @param {Object} options
* @param {boolean} [options.allowRepeat=false]
* @param {boolean} [options.bypassEditableProtection=false]
* @param {boolean} [options.global=false]
* @param {HotkeyCallback} callback
* @param {HotkeyOptions} [options]
* @returns {() => void}
*/
add(hotkey, callback, options = {}) {
@@ -391,3 +437,4 @@ export const hotkeyService = {
};
registry.category("services").add("hotkey", hotkeyService);
/** @typedef {ReturnType<hotkeyService["start"]>} HotkeyService */
+5 -3
View File
@@ -1,13 +1,15 @@
/** @odoo-module **/
/**
* @param {Document} activeElement
* @param {DOMString} selector
* @param {Element} activeElement
* @param {String} selector
* @returns all selected and visible elements present in the activeElement
*/
export function getVisibleElements(activeElement, selector) {
const visibleElements = [];
for (const el of activeElement.querySelectorAll(selector)) {
/** @type {NodeListOf<HTMLElement>} */
const elements = activeElement.querySelectorAll(selector);
for (const el of elements) {
const isVisible = el.offsetWidth > 0 && el.offsetHeight > 0;
if (isVisible) {
visibleElements.push(el);
@@ -16,7 +16,7 @@ import {
triggerHotkey,
} from "../../helpers/utils";
const { Component, useState, xml } = owl;
const { Component, useRef, useState, xml } = owl;
const serviceRegistry = registry.category("services");
let env;
@@ -242,18 +242,18 @@ QUnit.test("non-MacOS usability", async (assert) => {
const hotkey = env.services.hotkey;
const key = "q";
const commentEventAttrs = { key, bubbles: true };
// On non-MacOS, ALT is NOT replaced by CONTROL key
let removeHotkey = hotkey.add(`alt+${key}`, () => assert.step(`alt+${key}`));
await nextTick();
let keydown = new KeyboardEvent("keydown", { key, altKey: true });
window.dispatchEvent(keydown);
let keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, altKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([`alt+${key}`]);
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
@@ -263,13 +263,13 @@ QUnit.test("non-MacOS usability", async (assert) => {
removeHotkey = hotkey.add(`control+${key}`, () => assert.step(`control+${key}`));
await nextTick();
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([`control+${key}`]);
keydown = new KeyboardEvent("keydown", { key, metaKey: true });
window.dispatchEvent(keydown);
keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, metaKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
@@ -407,18 +407,19 @@ QUnit.test("MacOS usability", async (assert) => {
const hotkey = env.services.hotkey;
const key = "q";
const commentEventAttrs = { key, bubbles: true };
// On MacOS, ALT is replaced by CONTROL key
let removeHotkey = hotkey.add(`alt+${key}`, () => assert.step(`alt+${key}`));
await nextTick();
let keydown = new KeyboardEvent("keydown", { key, altKey: true });
window.dispatchEvent(keydown);
let keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, altKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([`alt+${key}`]);
@@ -428,13 +429,13 @@ QUnit.test("MacOS usability", async (assert) => {
removeHotkey = hotkey.add(`control+${key}`, () => assert.step(`control+${key}`));
await nextTick();
keydown = new KeyboardEvent("keydown", { key, ctrlKey: true });
window.dispatchEvent(keydown);
keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, ctrlKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
keydown = new KeyboardEvent("keydown", { key, metaKey: true });
window.dispatchEvent(keydown);
keydown = new KeyboardEvent("keydown", { ...commentEventAttrs, metaKey: true });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([`control+${key}`]);
@@ -761,11 +762,12 @@ QUnit.test("protects editable elements", async (assert) => {
const input = target.querySelector(".foo");
assert.verifySteps([]);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
triggerHotkey("ArrowLeft");
await nextTick();
assert.verifySteps(["called"]);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
input.focus();
triggerHotkey("ArrowLeft");
await nextTick();
assert.verifySteps(
[],
@@ -785,11 +787,12 @@ QUnit.test("protects editable elements: can bypassEditableProtection", async (as
const input = target.querySelector(".foo");
assert.verifySteps([]);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
triggerHotkey("ArrowLeft");
await nextTick();
assert.verifySteps(["called"]);
input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
input.focus();
triggerHotkey("ArrowLeft");
await nextTick();
assert.verifySteps(
["called"],
@@ -809,15 +812,17 @@ QUnit.test("protects editable elements: an editable can allow hotkeys", async (a
const barInput = target.querySelector(".bar");
assert.verifySteps([]);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
triggerHotkey("ArrowLeft");
await nextTick();
assert.verifySteps(["called"]);
fooInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
fooInput.focus();
triggerHotkey("ArrowLeft");
await nextTick();
assert.verifySteps(["called"], "the callback gets called as the foo editable allows it");
barInput.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
barInput.focus();
triggerHotkey("ArrowLeft");
await nextTick();
assert.verifySteps(
[],
@@ -829,17 +834,21 @@ QUnit.test("ignore numpad keys", async (assert) => {
assert.expect(3);
const key = "1";
const commonEventAttrs = {
altKey: true,
bubbles: true,
};
env.services.hotkey.add(`alt+${key}`, () => assert.step(key));
await nextTick();
let keydown = new KeyboardEvent("keydown", { key, code: "Numpad1", altKey: true });
window.dispatchEvent(keydown);
let keydown = new KeyboardEvent("keydown", { ...commonEventAttrs, key, code: "Numpad1" });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps([]);
keydown = new KeyboardEvent("keydown", { key: "&", code: "Digit1", altKey: true });
window.dispatchEvent(keydown);
keydown = new KeyboardEvent("keydown", { ...commonEventAttrs, key: "&", code: "Digit1" });
document.activeElement.dispatchEvent(keydown);
await nextTick();
assert.verifySteps(["1"]);
});
@@ -874,3 +883,215 @@ QUnit.test("within iframes", async (assert) => {
await nextTick();
assert.verifySteps(["called"]);
});
QUnit.test("callback: received context", async (assert) => {
class A extends Component {
setup() {
useHotkey("a", (context) => {
assert.deepEqual(context, {
area: undefined,
target: document.activeElement,
});
});
}
}
A.template = xml``;
class B extends Component {
setup() {
useHotkey(
"b",
(context) => {
assert.deepEqual(context, {
area: target,
target: target,
});
},
{ area: () => target }
);
}
}
B.template = xml``;
await mount(A, target, { env });
await mount(B, target, { env });
triggerHotkey("A");
triggerHotkey("B");
await nextTick();
});
QUnit.test("operating area can be restricted", async (assert) => {
class A extends Component {
setup() {
const areaRef = useRef("area");
useHotkey(
"space",
({ area }) => {
assert.step("RGNTDJÛ!");
assert.strictEqual(area, target.querySelector(".two"));
},
{
area: () => areaRef.el,
}
);
}
}
A.template = xml`
<div class="one" tabindex="0"/>
<div class="two" tabindex="0" t-ref="area"/>
`;
await mount(A, target, { env });
target.querySelector(".one").focus();
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
target.querySelector(".two").focus();
triggerHotkey("Space");
await nextTick();
assert.verifySteps(["RGNTDJÛ!"]);
});
QUnit.test("operating area and UI active element", async (assert) => {
class UIOwnershipTakerComponent extends Component {
setup() {
useActiveElement("bouh");
}
}
UIOwnershipTakerComponent.template = xml`<p class="owner" t-ref="bouh">bouh</p>`;
class C extends Component {
setup() {
this.state = useState({ foo: false });
const areaRef = useRef("area");
useHotkey(
"space",
({ area }) => {
assert.step("RGNTDJÛ!");
assert.strictEqual(area, target.querySelector(".two"));
},
{
area: () => areaRef.el,
}
);
useHotkey(
"backspace",
({ area }) => {
assert.step("RGNTDJÛ! (global)");
assert.strictEqual(area, target.querySelector(".two"));
},
{
area: () => areaRef.el,
global: true,
}
);
}
}
C.components = { UIOwnershipTakerComponent };
C.template = xml`
<main>
<UIOwnershipTakerComponent t-if="state.foo" />
<div class="one" tabindex="0"/>
<div class="two" tabindex="0" t-ref="area"/>
</main>
`;
const comp = await mount(C, target, { env });
assert.strictEqual(env.services.ui.activeElement, document);
// Show the UIOwnershipTaker
comp.state.foo = true;
await nextTick();
assert.hasClass(env.services.ui.activeElement, "owner");
// Trigger hotkeys from the 'one'
target.querySelector(".one").focus();
triggerHotkey("Space");
triggerHotkey("BackSpace");
await nextTick();
assert.verifySteps([]);
// Trigger hotkeys from the 'two'
target.querySelector(".two").focus();
triggerHotkey("Space");
triggerHotkey("BackSpace");
await nextTick();
assert.verifySteps(["RGNTDJÛ! (global)"]);
});
QUnit.test("validating option", async (assert) => {
let isValid = false;
class A extends Component {
setup() {
useHotkey(
"space",
() => {
assert.step("RGNTDJÛ!");
},
{
validate: (eventTarget) => {
assert.strictEqual(eventTarget, document.activeElement);
return isValid;
},
}
);
}
}
A.template = xml``;
await mount(A, target, { env });
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
isValid = true;
triggerHotkey("Space");
await nextTick();
assert.verifySteps(["RGNTDJÛ!"]);
});
QUnit.test("operation area with validating option", async (assert) => {
let isValid;
class A extends Component {
setup() {
const areaRef = useRef("area");
useHotkey(
"space",
() => {
assert.step("RGNTDJÛ!");
},
{ area: () => areaRef.el, validate: () => isValid }
);
}
}
A.template = xml`
<div class="one" tabindex="0"/>
<div class="two" tabindex="0" t-ref="area"/>
`;
await mount(A, target, { env });
// Trigger hotkeys from the 'one'
target.querySelector(".one").focus();
isValid = false;
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
isValid = true;
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
// Trigger hotkeys from the 'two'
target.querySelector(".two").focus();
isValid = false;
triggerHotkey("Space");
await nextTick();
assert.verifySteps([]);
isValid = true;
triggerHotkey("Space");
await nextTick();
assert.verifySteps(["RGNTDJÛ!"]);
});