[IMP] web: add AutoComplete component

This commit introduces an AutoComplete component, which displays
an input and reacts to input events to display a list of suggestions
in a dropdown. The main usecase for this component is the Many2One
field.

Part-of: odoo/odoo#92475
This commit is contained in:
Michael (mcm)
2022-06-30 09:29:42 +02:00
committed by Aaron Bohy
parent ebb410accb
commit a8b13c2481
5 changed files with 629 additions and 23 deletions
@@ -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,
});
@@ -0,0 +1,11 @@
.o-autocomplete {
.o-autocomplete--input {
width: 100%;
}
.o-autocomplete--dropdown-menu {
left: 0;
right: 0;
}
}
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="web.AutoComplete" owl="1">
<div class="o-autocomplete dropdown" t-ref="root">
<input
type="text"
t-att-id="props.id"
class="o-autocomplete--input o_input"
t-att-placeholder="props.placeholder"
t-att-value="props.value"
t-on-blur="onInputBlur"
t-on-click="onInputClick"
t-on-change="onInputChange"
t-on-input="debouncedOnInput"
t-on-keydown="onInputKeydown"
t-ref="input"
/>
<t t-if="isOpened">
<ul class="o-autocomplete--dropdown-menu dropdown-menu ui-widget ui-autocomplete show" t-on-mousedown.prevent="" t-ref="sourcesList">
<t t-foreach="sources" t-as="source" t-key="source.id">
<t t-if="source.isLoading">
<li class="o-autocomplete--dropdown-item ui-menu-item">
<a href="#" class="dropdown-item ui-menu-item-wrapper">
<i class="fa fa-spin fa-circle-o-notch" /> <t t-esc="source.placeholder" />
</a>
</li>
</t>
<t t-else="">
<t t-foreach="source.options" t-as="option" t-key="option.id">
<li
class="o-autocomplete--dropdown-item ui-menu-item"
t-att-class="option.classList"
t-on-mouseenter="() => this.onOptionMouseEnter([source_index, option_index])"
t-on-mouseleave="() => this.onOptionMouseLeave([source_index, option_index])"
t-on-click="() => this.onOptionClick([source_index, option_index])"
>
<a
href="#"
class="dropdown-item ui-menu-item-wrapper"
t-att-class="{ 'ui-state-active': isActiveSourceOption([source_index, option_index]) }"
>
<t t-if="source.optionTemplate">
<t t-call="{{ source.optionTemplate }}" />
</t>
<t t-else="">
<t t-esc="option.label" />
</t>
</a>
</li>
</t>
</t>
</t>
</ul>
</t>
</div>
</t>
</templates>
+37 -23
View File
@@ -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;
}
@@ -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`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
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`
<AutoComplete
value="state.value"
sources="sources"
onSelect="(option) => this.onSelect(option)"
/>
`;
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`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
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`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
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`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
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`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
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`
<AutoComplete
value="'Hello'"
sources="[{ options: [{ label: 'World' }, { label: 'Hello' }] }]"
onSelect="() => {}"
/>
`;
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");
});
});