[IMP] web: introduce useTagNavigationHook

Currently, the newly created component `MultiRecordSelector` (https://github.com/odoo/odoo/commit/bd0f2c6d799961b0a41222652eae7214c01dc5fe)
does not support the keyboard navigation between its tags (selecting or
deleting a tag). This behavior is currently implemented in the
`Many2ManyTagsField` component. This commit extract this behavior from
the `Many2ManyTagsField` component into a new hook that will be used by
the `MultiRecordSelector` component.

Part of task-id 3433922

Part-of: odoo/odoo#138464
This commit is contained in:
Pierre Rousseau
2023-10-13 11:46:21 +00:00
parent 78a859e0a1
commit d949e001c9
4 changed files with 157 additions and 102 deletions
@@ -0,0 +1,137 @@
/** @odoo-module */
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import { useEffect, useRef } from "@odoo/owl";
/**
* This hook allows to navigate between tags in a record selector. It also
* allows to delete tags with the backspace key.
* It is meant to be used in component which contains both the components
* `Autocomplete` and `TagList`.
*
* @param {string} refName Name of the t-ref which contains the `Autocomplete` and `TagList` components.
* @param {Function} deleteTag Function to be called when a tag is deleted. It should take the index of the tag to delete as parameter.
* @returns {Function} Function to be called when a tag is focused and a key is pressed. It should be passed to the `onKeydown` prop of the `Tag` component.
*/
export function useTagNavigation(refName, deleteTag) {
const ref = useRef(refName);
useEffect(
(autocomplete) => {
if (!autocomplete) {
return;
}
autocomplete.addEventListener("keydown", onAutoCompleteKeydown);
return () => {
autocomplete.removeEventListener("keydown", onAutoCompleteKeydown);
};
},
() => [ref.el?.querySelector(".o-autocomplete")]
);
/**
* Focus the tag at the given index. If no index is given, focus the rightmost tag.
* @param {number|undefined} index Index of the tag to focus. If undefined, focus the rightmost tag.
*/
function focusTag(index) {
const tags = ref.el.getElementsByClassName("o_tag");
if (tags.length) {
if (index === undefined) {
tags[tags.length - 1].focus();
} else {
tags[index].focus();
}
}
}
/**
* Function to be called when a key is pressed in the `Autocomplete` component.
*
* @param {Event} ev
*/
function onAutoCompleteKeydown(ev) {
if (ev.isComposing) {
// This case happens with an IME for example: we let it handle all key events.
return;
}
const hotkey = getActiveHotkey(ev);
const input = ev.target.closest(".o-autocomplete--input");
const autoCompleteMenuOpened = !!ref.el.querySelector(".o-autocomplete--dropdown-menu");
switch (hotkey) {
case "arrowleft": {
if (input.selectionStart || autoCompleteMenuOpened) {
return;
}
// focus rightmost tag if any.
focusTag();
break;
}
case "arrowright": {
if (input.selectionStart !== input.value.length || autoCompleteMenuOpened) {
return;
}
// focus leftmost tag if any.
focusTag(0);
break;
}
case "backspace": {
if (input.value) {
return;
}
const tags = ref.el.getElementsByClassName("o_tag");
if (tags.length) {
deleteTag(tags.length - 1);
}
break;
}
default:
return;
}
ev.preventDefault();
ev.stopPropagation();
}
/**
* Function to be called when a key is pressed in the `Tag` component.
* It should be passed to the `onKeydown` prop of the `Tag` component.
*
* @param {Event} ev
*/
function onTagKeydown(ev) {
const hotkey = getActiveHotkey(ev);
const tags = [...ref.el.getElementsByClassName("o_tag")];
const closestTag = ev.target.closest(".o_tag");
const tagIndex = tags.indexOf(closestTag);
const input = ref.el.querySelector(".o-autocomplete--input");
switch (hotkey) {
case "arrowleft": {
if (tagIndex === 0) {
input.focus();
} else {
focusTag(tagIndex - 1);
}
break;
}
case "arrowright": {
if (tagIndex === tags.length - 1) {
input.focus();
} else {
focusTag(tagIndex + 1);
}
break;
}
case "backspace": {
input.focus();
deleteTag(tagIndex);
break;
}
default:
return;
}
ev.preventDefault();
ev.stopPropagation();
}
return onTagKeydown;
}
@@ -5,7 +5,6 @@ import { CheckBox } from "@web/core/checkbox/checkbox";
import { ColorList } from "@web/core/colorlist/colorlist";
import { Domain } from "@web/core/domain";
import { evaluateBooleanExpr } from "@web/core/py_js/py";
import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
import {
Many2XAutocomplete,
useActiveActions,
@@ -16,6 +15,7 @@ import { standardFieldProps } from "../standard_field_props";
import { TagsList } from "@web/core/tags_list/tags_list";
import { usePopover } from "@web/core/popover/popover_hook";
import { useService } from "@web/core/utils/hooks";
import { useTagNavigation } from "@web/core/record_selectors/tag_navigation_hook";
import { Component, useRef } from "@odoo/owl";
@@ -64,7 +64,10 @@ export class Many2ManyTagsField extends Component {
this.popover = usePopover(this.constructor.components.Popover);
this.dialog = useService("dialog");
this.dialogClose = [];
this.onTagKeydown = useTagNavigation(
"many2ManyTagsField",
this.deleteTagByIndex.bind(this)
);
this.autoCompleteRef = useRef("autoComplete");
const { saveRecord, removeRecord } = useX2ManyCrud(
@@ -125,7 +128,12 @@ export class Many2ManyTagsField extends Component {
text: record.data.display_name,
colorIndex: record.data[this.props.colorField],
onDelete: !this.props.readonly ? () => this.deleteTag(record.id) : undefined,
onKeydown: this.onTagKeydown.bind(this),
onKeydown: (ev) => {
if (this.props.readonly) {
return;
}
this.onTagKeydown(ev);
},
};
}
@@ -139,6 +147,11 @@ export class Many2ManyTagsField extends Component {
return !this.props.readonly;
}
async deleteTagByIndex(index) {
const { id } = this.tags[index] || {};
this.deleteTag(id);
}
async deleteTag(id) {
const tagRecord = this.props.record.data[this.props.name].records.find(
(record) => record.id === id
@@ -157,103 +170,6 @@ export class Many2ManyTagsField extends Component {
Domain.not([["id", "in", this.props.record.data[this.props.name].currentIds]]),
]).toList(this.props.context);
}
focusTag(index) {
const autoCompleteParent = this.autoCompleteRef.el.parentElement;
const tags = autoCompleteParent.getElementsByClassName("o_tag");
if (tags.length) {
if (index === undefined) {
tags[tags.length - 1].focus();
} else {
tags[index].focus();
}
}
}
onAutoCompleteKeydown(ev) {
if (ev.isComposing) {
// This case happens with an IME for example: we let it handle all key events.
return;
}
const hotkey = getActiveHotkey(ev);
const input = ev.target.closest(".o-autocomplete--input");
const autoCompleteMenuOpened = !!this.autoCompleteRef.el.querySelector(
".o-autocomplete--dropdown-menu"
);
switch (hotkey) {
case "arrowleft": {
if (input.selectionStart || autoCompleteMenuOpened) {
return;
}
// focus rightmost tag if any.
this.focusTag();
break;
}
case "arrowright": {
if (input.selectionStart !== input.value.length || autoCompleteMenuOpened) {
return;
}
// focus leftmost tag if any.
this.focusTag(0);
break;
}
case "backspace": {
if (input.value) {
return;
}
const tags = this.tags;
if (tags.length) {
const { id } = tags[tags.length - 1];
this.deleteTag(id);
}
break;
}
default:
return;
}
ev.preventDefault();
ev.stopPropagation();
}
onTagKeydown(ev) {
if (this.props.readonly) {
return;
}
const hotkey = getActiveHotkey(ev);
const autoCompleteParent = this.autoCompleteRef.el.parentElement;
const tags = [...autoCompleteParent.getElementsByClassName("o_tag")];
const closestTag = ev.target.closest(".o_tag");
const tagIndex = tags.indexOf(closestTag);
const input = this.autoCompleteRef.el.querySelector(".o-autocomplete--input");
switch (hotkey) {
case "arrowleft": {
if (tagIndex === 0) {
input.focus();
} else {
this.focusTag(tagIndex - 1);
}
break;
}
case "arrowright": {
if (tagIndex === tags.length - 1) {
input.focus();
} else {
this.focusTag(tagIndex + 1);
}
break;
}
case "backspace": {
input.focus();
const { id } = this.tags[tagIndex] || {};
this.deleteTag(id);
break;
}
default:
return;
}
ev.preventDefault();
ev.stopPropagation();
}
}
export const many2ManyTagsField = {
@@ -5,9 +5,10 @@
<div
class="o_field_tags d-inline-flex flex-wrap gap-1"
t-att-class="{'o_tags_input o_input': !props.readonly}"
t-ref="many2ManyTagsField"
>
<TagsList tags="tags"/>
<div t-if="showM2OSelectionField" class="o_field_many2many_selection d-inline-flex w-100" t-on-keydown="onAutoCompleteKeydown" t-ref="autoComplete">
<div t-if="showM2OSelectionField" class="o_field_many2many_selection d-inline-flex w-100" t-ref="autoComplete">
<Many2XAutocomplete
id="props.id"
placeholder="tags.length ? '' : props.placeholder"
@@ -5,9 +5,10 @@
<div
class="many2many_tags_avatar_field_container o_field_tags d-inline-flex flex-wrap mw-100 gap-1"
t-att-class="{'o_tags_input o_input': !props.readonly}"
t-ref="many2ManyTagsField"
>
<TagsList tags="tags" itemsVisible="itemsVisible"/>
<div t-if="showM2OSelectionField" class="o_field_many2many_selection d-inline-flex w-100" t-on-keydown="onAutoCompleteKeydown" t-ref="autoComplete">
<div t-if="showM2OSelectionField" class="o_field_many2many_selection d-inline-flex w-100" t-ref="autoComplete">
<Many2XAutocomplete
id="props.id"
placeholder="tags.length ? '' : props.placeholder"