diff --git a/addons/web/static/src/core/utils/hooks.js b/addons/web/static/src/core/utils/hooks.js index bd883071f59..6764196b86a 100644 --- a/addons/web/static/src/core/utils/hooks.js +++ b/addons/web/static/src/core/utils/hooks.js @@ -214,6 +214,47 @@ export function useService(serviceName) { return service; } +// ----------------------------------------------------------------------------- +// useSpellCheck +// ----------------------------------------------------------------------------- + +/** + * To avoid elements to keep their spellcheck appearance when they are no + * longer in focus. We only add this attribute when needed. To disable this + * behavior, use the spellcheck attribute on the element. + */ +export function useSpellCheck({ refName } = {}) { + const elements = []; + const ref = useRef(refName || "spellcheck"); + function toggleSpellcheck(ev) { + ev.target.spellcheck = document.activeElement === ev.target; + } + useEffect( + (el) => { + if (el) { + const inputs = + ["INPUT", "TEXTAREA"].includes(el.nodeName) || el.contenteditable + ? [el] + : el.querySelectorAll("input, textarea, [contenteditable=true]"); + inputs.forEach((input) => { + if (input.spellcheck !== false) { + elements.push(input); + input.addEventListener("focus", toggleSpellcheck); + input.addEventListener("blur", toggleSpellcheck); + } + }); + } + return () => { + elements.forEach((input) => { + input.removeEventListener("focus", toggleSpellcheck); + input.removeEventListener("blur", toggleSpellcheck); + }); + }; + }, + () => [ref.el] + ); +} + /** * @typedef {Function} ForwardRef * @property {HTMLElement | undefined} el diff --git a/addons/web/static/src/views/fields/text/text_field.js b/addons/web/static/src/views/fields/text/text_field.js index 5870b86d070..588d5cba906 100644 --- a/addons/web/static/src/views/fields/text/text_field.js +++ b/addons/web/static/src/views/fields/text/text_field.js @@ -2,11 +2,12 @@ import { registry } from "@web/core/registry"; import { _lt } from "@web/core/l10n/translation"; +import { useSpellCheck } from "@web/core/utils/hooks"; import { useInputField } from "../input_field_hook"; import { standardFieldProps } from "../standard_field_props"; import { TranslationButton } from "../translation_button"; import { useDynamicPlaceholder } from "../dynamicplaceholder_hook"; -import { parseInteger } from '../parsers'; +import { parseInteger } from "../parsers"; import { Component, useEffect, onMounted, onWillUnmount, useRef } from "@odoo/owl"; @@ -17,6 +18,7 @@ export class TextField extends Component { } this.textareaRef = useRef("textarea"); useInputField({ getValue: () => this.props.value || "", refName: "textarea" }); + useSpellCheck({ refName: "textarea" }); useEffect(() => { if (!this.props.readonly) { @@ -30,34 +32,33 @@ export class TextField extends Component { if (ev.key === this.dynamicPlaceholder.TRIGGER_KEY && ev.target === this.textareaRef.el) { const baseModel = this.props.record.data.mailing_model_real; if (baseModel) { - await this.dynamicPlaceholder.open( - this.textareaRef.el, - baseModel, - { - validateCallback: this.onDynamicPlaceholderValidate.bind(this), - closeCallback: this.onDynamicPlaceholderClose.bind(this) - } - ); + await this.dynamicPlaceholder.open(this.textareaRef.el, baseModel, { + validateCallback: this.onDynamicPlaceholderValidate.bind(this), + closeCallback: this.onDynamicPlaceholderClose.bind(this), + }); } } } onMounted() { if (this.props.dynamicPlaceholder) { this.keydownListenerCallback = this.onKeydownListener.bind(this); - document.addEventListener('keydown', this.keydownListenerCallback); + document.addEventListener("keydown", this.keydownListenerCallback); } } onWillUnmount() { if (this.props.dynamicPlaceholder) { - document.removeEventListener('keydown', this.keydownListenerCallback); + document.removeEventListener("keydown", this.keydownListenerCallback); } } onDynamicPlaceholderValidate(chain, defaultValue) { if (chain) { const triggerKeyReplaceRegex = new RegExp(`${this.dynamicPlaceholder.TRIGGER_KEY}$`); - let dynamicPlaceholder = "{{object." + chain.join('.'); - dynamicPlaceholder += defaultValue && defaultValue !== '' ? ` or '''${defaultValue}'''}}` : '}}'; - this.props.update(this.textareaRef.el.value.replace(triggerKeyReplaceRegex, '') + dynamicPlaceholder); + let dynamicPlaceholder = "{{object." + chain.join("."); + dynamicPlaceholder += + defaultValue && defaultValue !== "" ? ` or '''${defaultValue}'''}}` : "}}"; + this.props.update( + this.textareaRef.el.value.replace(triggerKeyReplaceRegex, "") + dynamicPlaceholder + ); } } onDynamicPlaceholderClose() { @@ -114,7 +115,7 @@ TextField.props = { ...standardFieldProps, isTranslatable: { type: Boolean, optional: true }, placeholder: { type: String, optional: true }, - dynamicPlaceholder: { type: Boolean, optional: true}, + dynamicPlaceholder: { type: Boolean, optional: true }, rowCount: { type: Number, optional: true }, }; diff --git a/addons/web/static/tests/core/utils/hooks_tests.js b/addons/web/static/tests/core/utils/hooks_tests.js index 7bbaf633558..4515fcf14b6 100644 --- a/addons/web/static/tests/core/utils/hooks_tests.js +++ b/addons/web/static/tests/core/utils/hooks_tests.js @@ -1,7 +1,15 @@ /** @odoo-module **/ import { uiService } from "@web/core/ui/ui_service"; -import { useAutofocus, useBus, useChildRef, useForwardRefToParent, useListener, useService } from "@web/core/utils/hooks"; +import { + useAutofocus, + useBus, + useChildRef, + useForwardRefToParent, + useListener, + useService, + useSpellCheck, +} from "@web/core/utils/hooks"; import { registry } from "@web/core/registry"; import { makeTestEnv } from "@web/../tests/helpers/mock_env"; import { @@ -424,6 +432,184 @@ QUnit.module("utils", () => { assert.strictEqual(nbCalls, 8); }); + QUnit.module("useSpellCheck"); + + QUnit.test("useSpellCheck: ref is on the textarea", async function (assert) { + class MyComponent extends Component { + setup() { + useSpellCheck(); + } + } + MyComponent.template = xml`