[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:
committed by
Aaron Bohy
parent
ebb410accb
commit
a8b13c2481
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user