[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:
@@ -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"
|
||||
|
||||
+2
-1
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user