[IMP] web: only enable spellcheck on focused inputs
This commit adds the ability to enable spellcheck on input/textarea only if they are in focus. This avoids the UI to be bloated with unwanted underlined text on unfocused elements. It is especially useful in a context of form view in always edit mode. A core hook has been introduced to allow other components to use the same behavior without reimplementing it. It is currently used by the TextField. It means it is also used in the HtmlField. Tests have been written, both for the field component, and the core hook. task #2861428 Part-of: odoo/odoo#106866
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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`<div><textarea t-ref="spellcheck" class="textArea"/></div>`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
await mount(MyComponent, target, { env });
|
||||
const textArea = target.querySelector(".textArea");
|
||||
assert.strictEqual(textArea.spellcheck, true, "by default, spellcheck is enabled");
|
||||
textArea.focus();
|
||||
textArea.blur();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
false,
|
||||
"spellcheck is disabled once the element has lost its focus"
|
||||
);
|
||||
textArea.focus();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
true,
|
||||
"spellcheck is re-enabled once the element is focused"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("useSpellCheck: use a different refName", async function (assert) {
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
useSpellCheck({ refName: "myreference" });
|
||||
}
|
||||
}
|
||||
MyComponent.template = xml`<div><textarea t-ref="myreference" class="textArea"/></div>`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
await mount(MyComponent, target, { env });
|
||||
const textArea = target.querySelector(".textArea");
|
||||
assert.strictEqual(textArea.spellcheck, true, "by default, spellcheck is enabled");
|
||||
textArea.focus();
|
||||
textArea.blur();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
false,
|
||||
"spellcheck is disabled once the element has lost its focus"
|
||||
);
|
||||
textArea.focus();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
true,
|
||||
"spellcheck is re-enabled once the element is focused"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"useSpellCheck: ref is on the root element and two editable elements",
|
||||
async function (assert) {
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
useSpellCheck();
|
||||
}
|
||||
}
|
||||
MyComponent.template = xml`
|
||||
<div t-ref="spellcheck">
|
||||
<textarea class="textArea"/>
|
||||
<div contenteditable="true" class="editableDiv"/>
|
||||
</div>`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
await mount(MyComponent, target, { env });
|
||||
const textArea = target.querySelector(".textArea");
|
||||
const editableDiv = target.querySelector(".editableDiv");
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
true,
|
||||
"by default, spellcheck is enabled on the textarea"
|
||||
);
|
||||
assert.strictEqual(
|
||||
editableDiv.spellcheck,
|
||||
true,
|
||||
"by default, spellcheck is enabled on the editable div"
|
||||
);
|
||||
textArea.focus();
|
||||
textArea.blur();
|
||||
editableDiv.focus();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
false,
|
||||
"spellcheck is disabled once the element has lost its focus"
|
||||
);
|
||||
editableDiv.blur();
|
||||
assert.strictEqual(
|
||||
editableDiv.spellcheck,
|
||||
false,
|
||||
"spellcheck is disabled once the element has lost its focus"
|
||||
);
|
||||
textArea.focus();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
true,
|
||||
"spellcheck is re-enabled once the element is focused"
|
||||
);
|
||||
assert.strictEqual(
|
||||
editableDiv.spellcheck,
|
||||
false,
|
||||
"spellcheck is still disabled as it is not focused"
|
||||
);
|
||||
editableDiv.focus();
|
||||
assert.strictEqual(
|
||||
editableDiv.spellcheck,
|
||||
true,
|
||||
"spellcheck is re-enabled once the element is focused"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test(
|
||||
"useSpellCheck: ref is on the root element and one element has disabled the spellcheck",
|
||||
async function (assert) {
|
||||
class MyComponent extends Component {
|
||||
setup() {
|
||||
useSpellCheck();
|
||||
}
|
||||
}
|
||||
MyComponent.template = xml`
|
||||
<div t-ref="spellcheck">
|
||||
<textarea class="textArea"/>
|
||||
<div contenteditable="true" spellcheck="false" class="editableDiv"/>
|
||||
</div>`;
|
||||
|
||||
const env = await makeTestEnv();
|
||||
const target = getFixture();
|
||||
await mount(MyComponent, target, { env });
|
||||
const textArea = target.querySelector(".textArea");
|
||||
const editableDiv = target.querySelector(".editableDiv");
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
true,
|
||||
"by default, spellcheck is enabled on the textarea"
|
||||
);
|
||||
assert.strictEqual(
|
||||
editableDiv.spellcheck,
|
||||
false,
|
||||
"by default, spellcheck is disabled on the editable div"
|
||||
);
|
||||
textArea.focus();
|
||||
textArea.blur();
|
||||
editableDiv.focus();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
false,
|
||||
"spellcheck is disabled once the element has lost its focus"
|
||||
);
|
||||
assert.strictEqual(
|
||||
editableDiv.spellcheck,
|
||||
false,
|
||||
"spellcheck has not been enabled since it was disabled on purpose"
|
||||
);
|
||||
editableDiv.blur();
|
||||
assert.strictEqual(
|
||||
editableDiv.spellcheck,
|
||||
false,
|
||||
"spellcheck stays disabled once the element has lost its focus"
|
||||
);
|
||||
textArea.focus();
|
||||
assert.strictEqual(
|
||||
textArea.spellcheck,
|
||||
true,
|
||||
"spellcheck is re-enabled once the element is focused"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.module("useChildRef / useForwardRefToParent");
|
||||
|
||||
QUnit.test("simple usecase", async function (assert) {
|
||||
|
||||
@@ -241,4 +241,32 @@ QUnit.module("Fields", ({ beforeEach }) => {
|
||||
|
||||
await click(target, ".modal button.btn-primary"); // save
|
||||
});
|
||||
|
||||
QUnit.test("html fields: spellcheck is disabled on blur", async (assert) => {
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
serverData,
|
||||
arch: /* xml */ `<form><field name="txt" /></form>`,
|
||||
});
|
||||
|
||||
const textarea = target.querySelector(".o_field_html textarea");
|
||||
assert.strictEqual(textarea.spellcheck, true, "by default, spellcheck is enabled");
|
||||
textarea.focus();
|
||||
|
||||
await editInput(textarea, null, "nev walue");
|
||||
textarea.blur();
|
||||
assert.strictEqual(
|
||||
textarea.spellcheck,
|
||||
false,
|
||||
"spellcheck is disabled once the field has lost its focus"
|
||||
);
|
||||
textarea.focus();
|
||||
assert.strictEqual(
|
||||
textarea.spellcheck,
|
||||
true,
|
||||
"spellcheck is re-enabled once the field is focused"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user