diff --git a/addons/web/static/src/core/autocomplete/autocomplete.js b/addons/web/static/src/core/autocomplete/autocomplete.js
new file mode 100644
index 00000000000..d90b99c5eec
--- /dev/null
+++ b/addons/web/static/src/core/autocomplete/autocomplete.js
@@ -0,0 +1,318 @@
+/** @odoo-module **/
+
+import { useService } from "@web/core/utils/hooks";
+import { useDebounced } from "@web/core/utils/timing";
+import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service";
+import { usePosition } from "@web/core/position_hook";
+
+const { Component, useExternalListener, useRef, useState, useEffect } = owl;
+
+export class AutoComplete extends Component {
+ setup() {
+ this.nextSourceId = 0;
+ this.nextOptionId = 0;
+ this.sources = [];
+
+ this.state = useState({
+ navigationRev: 0,
+ optionsRev: 0,
+ open: false,
+ activeSourceOption: null,
+ });
+
+ this.inputRef = useRef("input");
+ this.root = useRef("root");
+ this.debouncedOnInput = useDebounced(this.onInput, this.constructor.timeout);
+ useExternalListener(window, "scroll", this.onWindowScroll, true);
+
+ this.hotkey = useService("hotkey");
+ this.hotkeysToRemove = [];
+
+ owl.onWillUpdateProps((nextProps) => {
+ if (nextProps.forceClose) {
+ this.inputRef.el.value = nextProps.value;
+ this.close();
+ }
+ });
+
+ // position and size
+ const sourcesListRef = useRef("sourcesList");
+ useEffect(
+ () => {
+ if (sourcesListRef.el) {
+ sourcesListRef.el.style.width =
+ this.inputRef.el.getBoundingClientRect().width + "px";
+ }
+ },
+ () => [sourcesListRef.el]
+ );
+ usePosition(() => this.inputRef.el, {
+ popper: "sourcesList",
+ });
+ }
+
+ get isOpened() {
+ return this.state.open;
+ }
+
+ open(useInput = false) {
+ this.state.open = true;
+ this.loadSources(useInput);
+ }
+
+ close() {
+ this.state.open = false;
+ this.state.activeSourceOption = null;
+ }
+
+ loadSources(useInput) {
+ const sources = [];
+ const proms = [];
+ for (const pSource of this.props.sources) {
+ const source = this.makeSource(pSource);
+ sources.push(source);
+
+ const options = this.loadOptions(
+ pSource.options,
+ useInput ? this.inputRef.el.value.trim() : ""
+ );
+ if (options instanceof Promise) {
+ source.isLoading = true;
+ const prom = options.then((options) => {
+ source.options = options.map((option) => this.makeOption(option));
+ source.isLoading = false;
+ this.state.optionsRev++;
+ });
+ proms.push(prom);
+ } else {
+ source.options = options.map((option) => this.makeOption(option));
+ }
+ }
+ this.sources = sources;
+ Promise.all(proms).then(() => {
+ this.navigate(0);
+ });
+ }
+ loadOptions(options, request) {
+ if (typeof options === "function") {
+ return options(request);
+ } else {
+ return options;
+ }
+ }
+ makeOption(option) {
+ return Object.assign(Object.create(option), {
+ id: ++this.nextOptionId,
+ });
+ }
+ makeSource(source) {
+ return {
+ id: ++this.nextSourceId,
+ options: [],
+ isLoading: false,
+ placeholder: source.placeholder,
+ optionTemplate: source.optionTemplate,
+ };
+ }
+
+ isActiveSourceOption([sourceIndex, optionIndex]) {
+ return (
+ this.state.activeSourceOption &&
+ this.state.activeSourceOption[0] === sourceIndex &&
+ this.state.activeSourceOption[1] === optionIndex
+ );
+ }
+ selectOption(indices, params = {}) {
+ this.inputRef.el.value = "";
+
+ const option = this.sources[indices[0]].options[indices[1]];
+ if (option.unselectable) {
+ return;
+ }
+
+ this.props.onSelect(option, {
+ ...params,
+ inputValue: this.inputRef.el.value.trim(),
+ input: this.inputRef.el,
+ });
+ this.close();
+ }
+
+ navigate(direction) {
+ let step = Math.sign(direction);
+ if (!step) {
+ this.state.activeSourceOption = null;
+ step = 1;
+ } else {
+ this.state.navigationRev++;
+ }
+
+ if (this.state.activeSourceOption) {
+ let [sourceIndex, optionIndex] = this.state.activeSourceOption;
+ let source = this.sources[sourceIndex];
+
+ optionIndex += step;
+ if (0 > optionIndex || optionIndex >= source.options.length) {
+ sourceIndex += step;
+ source = this.sources[sourceIndex];
+
+ while (source && source.isLoading) {
+ sourceIndex += step;
+ source = this.sources[sourceIndex];
+ }
+
+ if (source) {
+ optionIndex = step < 0 ? source.options.length - 1 : 0;
+ }
+ }
+
+ this.state.activeSourceOption = source ? [sourceIndex, optionIndex] : null;
+ } else {
+ let sourceIndex = step < 0 ? this.sources.length - 1 : 0;
+ let source = this.sources[sourceIndex];
+
+ while (source && source.isLoading) {
+ sourceIndex += step;
+ source = this.sources[sourceIndex];
+ }
+
+ if (source) {
+ const optionIndex = step < 0 ? source.options.length - 1 : 0;
+ this.state.activeSourceOption = [sourceIndex, optionIndex];
+ }
+ }
+ }
+
+ onInputBlur() {
+ const value = this.inputRef.el.value;
+ if (
+ this.props.autoSelect &&
+ this.state.activeSourceOption &&
+ value.length > 0 &&
+ value !== this.props.value
+ ) {
+ this.selectOption(this.state.activeSourceOption, { triggeredOnBlur: true });
+ } else {
+ this.props.onBlur({
+ inputValue: value,
+ });
+ this.close();
+ }
+ }
+ onInputClick() {
+ if (!this.isOpened) {
+ this.open(this.inputRef.el.value.trim() !== this.props.value);
+ } else {
+ this.close();
+ }
+ }
+ onInputChange() {
+ this.props.onChange({
+ inputValue: this.inputRef.el.value,
+ });
+ }
+ onInput() {
+ this.props.onInput({
+ inputValue: this.inputRef.el.value,
+ });
+ this.open(true);
+ }
+
+ onInputKeydown(ev) {
+ const hotkey = getActiveHotkey(ev);
+ switch (hotkey) {
+ case "enter":
+ if (!this.isOpened || !this.state.activeSourceOption) {
+ return;
+ }
+ this.selectOption(this.state.activeSourceOption);
+ break;
+ case "escape":
+ if (!this.isOpened) {
+ return;
+ }
+ this.close();
+ break;
+ case "tab":
+ if (!this.isOpened) {
+ return;
+ }
+ if (
+ this.props.autoSelect &&
+ this.state.activeSourceOption &&
+ (this.state.navigationRev > 0 || this.inputRef.el.value.length > 0)
+ ) {
+ this.selectOption(this.state.activeSourceOption);
+ }
+ this.close();
+ return;
+ case "arrowup":
+ this.navigate(-1);
+ if (!this.isOpened) {
+ this.open(true);
+ }
+ break;
+ case "arrowdown":
+ this.navigate(+1);
+ if (!this.isOpened) {
+ this.open(true);
+ }
+ break;
+ default:
+ return;
+ }
+
+ ev.stopPropagation();
+ ev.preventDefault();
+ }
+
+ onOptionMouseEnter(indices) {
+ this.state.activeSourceOption = indices;
+ }
+ onOptionMouseLeave() {
+ this.state.activeSourceOption = null;
+ }
+ onOptionClick(indices) {
+ this.selectOption(indices);
+ }
+
+ onWindowScroll(ev) {
+ if (this.isOpened && !this.root.el.contains(ev.target)) {
+ this.close();
+ }
+ }
+}
+Object.assign(AutoComplete, {
+ template: "web.AutoComplete",
+ props: {
+ value: { type: String },
+ id: { type: String, optional: true },
+ onSelect: { type: Function },
+ sources: {
+ type: Array,
+ element: {
+ type: Object,
+ shape: {
+ placeholder: { type: String, optional: true },
+ optionTemplate: { type: String, optional: true },
+ options: [Array, Function],
+ },
+ },
+ },
+ placeholder: { type: String, optional: true },
+ autoSelect: { type: Boolean, optional: true },
+ onInput: { type: Function, optional: true },
+ onChange: { type: Function, optional: true },
+ onBlur: { type: Function, optional: true },
+ forceClose: { type: Boolean, optional: true },
+ },
+ defaultProps: {
+ placeholder: "",
+ autoSelect: false,
+ onInput: () => {},
+ onChange: () => {},
+ onBlur: () => {},
+ forceClose: false,
+ },
+ timeout: 250,
+});
diff --git a/addons/web/static/src/core/autocomplete/autocomplete.scss b/addons/web/static/src/core/autocomplete/autocomplete.scss
new file mode 100644
index 00000000000..2f186ac15b1
--- /dev/null
+++ b/addons/web/static/src/core/autocomplete/autocomplete.scss
@@ -0,0 +1,11 @@
+
+.o-autocomplete {
+ .o-autocomplete--input {
+ width: 100%;
+ }
+
+ .o-autocomplete--dropdown-menu {
+ left: 0;
+ right: 0;
+ }
+}
diff --git a/addons/web/static/src/core/autocomplete/autocomplete.xml b/addons/web/static/src/core/autocomplete/autocomplete.xml
new file mode 100644
index 00000000000..2654ff87029
--- /dev/null
+++ b/addons/web/static/src/core/autocomplete/autocomplete.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/web/static/src/core/utils/timing.js b/addons/web/static/src/core/utils/timing.js
index 7cc64925030..b40470dab23 100644
--- a/addons/web/static/src/core/utils/timing.js
+++ b/addons/web/static/src/core/utils/timing.js
@@ -2,6 +2,8 @@
import { browser } from "@web/core/browser/browser";
+const { onWillUnmount, useComponent } = owl;
+
/**
* Creates a version of the function where only the last call between two
* animation frames is executed before the browser's next repaint. This
@@ -31,6 +33,33 @@ export function throttleForAnimation(func) {
);
}
+/**
+ * Returns a function, that, as long as it continues to be invoked, will be
+ * triggered every N milliseconds.
+ *
+ * @deprecated this function has behaviour that is unexpected considering its
+ * name, prefer _.throttle until this function is rewritten
+ * @param {Function} func
+ * @param {number} delay
+ * @returns {Function}
+ */
+export function throttle(func, delay) {
+ let waiting = false;
+ const funcName = func.name ? func.name + " (throttle)" : "throttle";
+ return {
+ [funcName](...args) {
+ const context = this;
+ if (!waiting) {
+ waiting = true;
+ browser.setTimeout(function () {
+ waiting = false;
+ func.call(context, ...args);
+ }, delay);
+ }
+ },
+ }[funcName];
+}
+
/**
* Creates and returns a new debounced version of the passed function (func)
* which will postpone its execution until after 'delay' milliseconds
@@ -81,28 +110,13 @@ export function debounce(func, delay, immediate = false) {
}
/**
- * Returns a function, that, as long as it continues to be invoked, will be
- * triggered every N milliseconds.
- *
- * @deprecated this function has behaviour that is unexpected considering its
- * name, prefer _.throttle until this function is rewritten
- * @param {Function} func
- * @param {number} delay
- * @returns {Function}
+ * Hook that returns a debounced version of the given function, and cancels
+ * the potential pending execution on willUnmount.
+ * @see debounce
*/
-export function throttle(func, delay) {
- let waiting = false;
- const funcName = func.name ? func.name + " (throttle)" : "throttle";
- return {
- [funcName](...args) {
- const context = this;
- if (!waiting) {
- waiting = true;
- browser.setTimeout(function () {
- waiting = false;
- func.call(context, ...args);
- }, delay);
- }
- },
- }[funcName];
+export function useDebounced(callback, delay) {
+ const component = useComponent();
+ const debounced = debounce(callback.bind(component), delay);
+ onWillUnmount(() => debounced.cancel());
+ return debounced;
}
diff --git a/addons/web/static/tests/core/autocomplete_tests.js b/addons/web/static/tests/core/autocomplete_tests.js
new file mode 100644
index 00000000000..5f1b07d1613
--- /dev/null
+++ b/addons/web/static/tests/core/autocomplete_tests.js
@@ -0,0 +1,204 @@
+/** @odoo-module **/
+
+import { browser } from "@web/core/browser/browser";
+import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
+import { registry } from "@web/core/registry";
+import { AutoComplete } from "@web/core/autocomplete/autocomplete";
+import { makeTestEnv } from "../helpers/mock_env";
+import {
+ click,
+ getFixture,
+ mount,
+ patchWithCleanup,
+ triggerEvent,
+ triggerEvents,
+} from "../helpers/utils";
+
+const { Component, useState, xml } = owl;
+
+const serviceRegistry = registry.category("services");
+
+let env;
+let target;
+
+QUnit.module("Components", (hooks) => {
+ hooks.beforeEach(async () => {
+ serviceRegistry.add("hotkey", hotkeyService);
+ env = await makeTestEnv();
+ target = getFixture();
+ patchWithCleanup(browser, {
+ setTimeout: (fn) => fn(),
+ });
+ });
+
+ QUnit.module("AutoComplete");
+
+ QUnit.test("can be rendered", async (assert) => {
+ class Parent extends Component {}
+ Parent.components = { AutoComplete };
+ Parent.template = xml`
+
+ `;
+
+ await mount(Parent, target, { env });
+ assert.containsOnce(target, ".o-autocomplete");
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+
+ await click(target, ".o-autocomplete--input");
+ assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
+
+ const options = [...target.querySelectorAll(".o-autocomplete--dropdown-item")];
+ assert.deepEqual(
+ options.map((el) => el.textContent),
+ ["World", "Hello"]
+ );
+ });
+
+ QUnit.test("select option", async (assert) => {
+ class Parent extends Component {
+ setup() {
+ this.state = useState({
+ value: "Hello",
+ });
+ }
+ get sources() {
+ return [
+ {
+ options: [{ label: "World" }, { label: "Hello" }],
+ },
+ ];
+ }
+ onSelect(option) {
+ this.state.value = option.label;
+ assert.step(option.label);
+ }
+ }
+ Parent.components = { AutoComplete };
+ Parent.template = xml`
+
+ `;
+
+ await mount(Parent, target, { env });
+ assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
+
+ await click(target, ".o-autocomplete--input");
+ await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[0]);
+ assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "World");
+ assert.verifySteps(["World"]);
+
+ await click(target, ".o-autocomplete--input");
+ await click(target.querySelectorAll(".o-autocomplete--dropdown-item")[1]);
+ assert.strictEqual(target.querySelector(".o-autocomplete--input").value, "Hello");
+ assert.verifySteps(["Hello"]);
+ });
+
+ QUnit.test("open dropdown on input", async (assert) => {
+ class Parent extends Component {}
+ Parent.components = { AutoComplete };
+ Parent.template = xml`
+
+ `;
+
+ await mount(Parent, target, { env });
+
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+ await triggerEvent(target, ".o-autocomplete--input", "input");
+ assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
+ });
+
+ QUnit.test("close dropdown on escape keydown", async (assert) => {
+ class Parent extends Component {}
+ Parent.components = { AutoComplete };
+ Parent.template = xml`
+
+ `;
+
+ await mount(Parent, target, { env });
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+
+ await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
+ assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
+
+ await triggerEvent(target, ".o-autocomplete--input", "keydown", { key: "Escape" });
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+ });
+
+ QUnit.test("scroll outside should close dropdown", async (assert) => {
+ class Parent extends Component {}
+ Parent.components = { AutoComplete };
+ Parent.template = xml`
+
+ `;
+
+ await mount(Parent, target, { env });
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+
+ await click(target, ".o-autocomplete--input");
+ assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
+
+ await triggerEvent(target, null, "scroll");
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+ });
+
+ QUnit.test("scroll inside should keep dropdown open", async (assert) => {
+ class Parent extends Component {}
+ Parent.components = { AutoComplete };
+ Parent.template = xml`
+
+ `;
+
+ await mount(Parent, target, { env });
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+
+ await click(target, ".o-autocomplete--input");
+ assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
+
+ await triggerEvent(target, ".o-autocomplete--dropdown-menu", "scroll");
+ assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
+ });
+
+ QUnit.test("losing focus should close dropdown", async (assert) => {
+ class Parent extends Component {}
+ Parent.components = { AutoComplete };
+ Parent.template = xml`
+
+ `;
+
+ await mount(Parent, target, { env });
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+
+ await triggerEvents(target, ".o-autocomplete--input", ["focus", "click"]);
+ assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
+
+ await triggerEvent(target, ".o-autocomplete--input", "blur");
+ assert.containsNone(target, ".o-autocomplete--dropdown-menu");
+ });
+});