diff --git a/addons/web/static/src/core/record_selectors/tag_navigation_hook.js b/addons/web/static/src/core/record_selectors/tag_navigation_hook.js new file mode 100644 index 00000000000..dd53f110a18 --- /dev/null +++ b/addons/web/static/src/core/record_selectors/tag_navigation_hook.js @@ -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; +} diff --git a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js index 6d691e44167..69e83eef398 100644 --- a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js +++ b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.js @@ -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 = { diff --git a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.xml b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.xml index a3964937526..66662745ccb 100644 --- a/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.xml +++ b/addons/web/static/src/views/fields/many2many_tags/many2many_tags_field.xml @@ -5,9 +5,10 @@