[IMP] web: improve SelectMenu usability
This commit aims to make the SelectMenu easier to use with a better navigation system, and the ability to select a value with the enter key. The bottomArea slot no longer adds a div, which could allow to use DropdownItems in this slot, and navigate fluently from other options. Those improvements are required by task-3134491, which will replace a select2 input with the SelectMenu instead. Tests have been added for the navigation behavior and the selection with the enter key. closes odoo/odoo#119934 Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
@@ -83,7 +83,6 @@ export class SelectMenu extends Component {
|
||||
searchValue: "",
|
||||
});
|
||||
this.inputRef = useRef("inputRef");
|
||||
this.inputContainerRef = useRef("inputContainerRef");
|
||||
this.debouncedOnInput = useDebounced(
|
||||
() => this.onInput(this.inputRef.el ? this.inputRef.el.value.trim() : ""),
|
||||
250
|
||||
@@ -162,9 +161,24 @@ export class SelectMenu extends Component {
|
||||
this.state.searchValue = searchString;
|
||||
|
||||
// Get reference to dropdown container and scroll to the top.
|
||||
const inputContainer = this.inputContainerRef.el;
|
||||
if (inputContainer && inputContainer.parentNode) {
|
||||
inputContainer.parentNode.scrollTo(0, 0);
|
||||
const inputEl = this.inputRef.el;
|
||||
if (inputEl && inputEl.parentNode) {
|
||||
inputEl.parentNode.scrollTo(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
onSearchKeydown(ev) {
|
||||
if (ev.key === "ArrowDown" || ev.key === "Enter") {
|
||||
// Focus the first choice when navigating from the input using the arrow down key
|
||||
const target = ev.target.parentElement.querySelector(".o_select_menu_item");
|
||||
ev.target.classList.remove("focus");
|
||||
target?.classList.add("focus");
|
||||
target?.focus();
|
||||
ev.preventDefault();
|
||||
}
|
||||
if (ev.key === "Enter" && this.state.choices.length === 1) {
|
||||
// When there is only one displayed option, the enter key selects the value
|
||||
ev.target.parentElement.querySelector(".o_select_menu_item").click();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,14 @@
|
||||
max-height: 350px;
|
||||
border: 1px solid $o-brand-primary;
|
||||
|
||||
.o_select_menu_input, .o_select_menu_bottom_area {
|
||||
background-color: $dropdown-bg;
|
||||
.o_select_menu_sticky {
|
||||
background-color: $dropdown-bg !important;
|
||||
|
||||
&.o_select_menu_item {
|
||||
&:focus, &:hover {
|
||||
background: $dropdown-border-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_select_menu_group {
|
||||
top: 40px;
|
||||
|
||||
@@ -30,18 +30,18 @@
|
||||
<i class="fa fa-caret-down"></i>
|
||||
</span>
|
||||
</t>
|
||||
<div t-if="props.searchable" class="o_select_menu_input p-2 position-sticky top-0 start-0" t-ref="inputContainerRef">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
t-ref="inputRef"
|
||||
t-on-input="debouncedOnInput"
|
||||
t-att-placeholder="props.searchPlaceholder"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
t-if="props.searchable"
|
||||
type="text"
|
||||
class="dropdown-item o_select_menu_sticky px-3 py-3 position-sticky top-0 start-0 border-bottom"
|
||||
t-ref="inputRef"
|
||||
t-on-input="debouncedOnInput"
|
||||
t-on-keydown="onSearchKeydown"
|
||||
t-att-placeholder="props.searchPlaceholder"
|
||||
autocomplete="selectMenuAutocompleteOff"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<t t-if="state.choices.length === 0">
|
||||
<span class="text-muted fst-italic ms-3">No result found</span>
|
||||
</t>
|
||||
@@ -61,9 +61,7 @@
|
||||
</t>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
<div t-if="props.slots and props.slots.bottomArea" class="o_select_menu_bottom_area position-sticky bottom-0">
|
||||
<t t-slot="bottomArea" data="state"/>
|
||||
</div>
|
||||
<t t-if="props.slots and props.slots.bottomArea" t-slot="bottomArea" data="state"/>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
click,
|
||||
triggerEvent,
|
||||
nextTick,
|
||||
triggerHotkey,
|
||||
editInput,
|
||||
} from "../helpers/utils";
|
||||
|
||||
@@ -171,8 +172,8 @@ QUnit.module("Web Components", (hooks) => {
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
await open();
|
||||
assert.containsOnce(target, ".o_select_menu_input");
|
||||
assert.equal(document.activeElement, target.querySelector(".o_select_menu_input input"));
|
||||
assert.containsOnce(target, "input.o_select_menu_sticky");
|
||||
assert.equal(document.activeElement, target.querySelector("input.o_select_menu_sticky"));
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
@@ -426,7 +427,7 @@ QUnit.module("Web Components", (hooks) => {
|
||||
>
|
||||
<span class="select_menu_test">Select something</span>
|
||||
<t t-set-slot="bottomArea" t-slot-scope="select">
|
||||
<span>
|
||||
<span class="o_select_menu_bottom_area">
|
||||
<t t-esc="state.value.length"/> items selected
|
||||
</span>
|
||||
</t>
|
||||
@@ -440,7 +441,7 @@ QUnit.module("Web Components", (hooks) => {
|
||||
"2 items selected"
|
||||
);
|
||||
|
||||
await click(target, ".o_select_menu_item:nth-child(2)");
|
||||
await click(target, ".o_select_menu_item:nth-child(3)");
|
||||
await open();
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_select_menu_bottom_area").textContent.trim(),
|
||||
@@ -481,7 +482,7 @@ QUnit.module("Web Components", (hooks) => {
|
||||
await open();
|
||||
assert.containsNone(target, ".coolClass");
|
||||
|
||||
await editInput(target, ".o_select_menu_input input", "coucou");
|
||||
await editInput(target, "input.o_select_menu_sticky", "coucou");
|
||||
assert.containsOnce(target, ".coolClass");
|
||||
|
||||
await click(target.querySelector(".coolClass"));
|
||||
@@ -651,6 +652,11 @@ QUnit.module("Web Components", (hooks) => {
|
||||
|
||||
// Select first choice
|
||||
await open();
|
||||
assert.containsNone(
|
||||
target,
|
||||
".o_select_menu_sticky.top-0",
|
||||
"Search box is not present."
|
||||
);
|
||||
assert.containsNone(
|
||||
target,
|
||||
".o_select_menu_item.o_select_active",
|
||||
@@ -766,4 +772,99 @@ QUnit.module("Web Components", (hooks) => {
|
||||
assert.containsNone(target, ".o_select_menu .o_tag", "There should be no tags.");
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("Navigation is possible from the input when it is focused", async (assert) => {
|
||||
assert.expect(6);
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({ value: "b" });
|
||||
this.choices = [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
{ label: "C", value: "c" },
|
||||
];
|
||||
}
|
||||
|
||||
onSelect(newValue) {
|
||||
assert.step(newValue);
|
||||
this.state.value = newValue;
|
||||
}
|
||||
}
|
||||
Parent.components = { SelectMenu };
|
||||
Parent.template = xml`
|
||||
<SelectMenu
|
||||
value="this.state.value"
|
||||
choices="this.choices"
|
||||
onSelect.bind="this.onSelect"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
await open();
|
||||
assert.equal(
|
||||
document.activeElement,
|
||||
target.querySelector("input.o_select_menu_sticky"),
|
||||
"search input is focused by default"
|
||||
);
|
||||
|
||||
await triggerHotkey("ArrowDown");
|
||||
assert.strictEqual(
|
||||
document.activeElement.textContent,
|
||||
"A",
|
||||
"first item is focused after keyboard navigation"
|
||||
);
|
||||
|
||||
await triggerHotkey("ArrowDown");
|
||||
assert.strictEqual(document.activeElement.textContent, "B", "second item is now focused");
|
||||
|
||||
await triggerHotkey("ArrowUp");
|
||||
await triggerHotkey("ArrowUp");
|
||||
assert.equal(
|
||||
document.activeElement,
|
||||
target.querySelector("input.o_select_menu_sticky"),
|
||||
"search input is focused again"
|
||||
);
|
||||
|
||||
await triggerHotkey("ArrowDown");
|
||||
await triggerHotkey("Enter");
|
||||
assert.verifySteps(["a"], "value has been selected after keyboard navigation");
|
||||
});
|
||||
|
||||
QUnit.test(
|
||||
"When only one choice is displayed, 'enter' key should select the value",
|
||||
async (assert) => {
|
||||
assert.expect(2);
|
||||
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({ value: "b" });
|
||||
this.choices = [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
{ label: "C", value: "c" },
|
||||
];
|
||||
}
|
||||
|
||||
onSelect(newValue) {
|
||||
assert.step(newValue);
|
||||
this.state.value = newValue;
|
||||
}
|
||||
}
|
||||
Parent.components = { SelectMenu };
|
||||
Parent.template = xml`
|
||||
<SelectMenu
|
||||
value="this.state.value"
|
||||
choices="this.choices"
|
||||
onSelect.bind="this.onSelect"
|
||||
/>
|
||||
`;
|
||||
|
||||
await mount(Parent, target, { env });
|
||||
await open();
|
||||
await editInput(target, "input.o_select_menu_sticky", "a");
|
||||
await triggerHotkey("Enter");
|
||||
assert.verifySteps(["a"], "value has been selected after keyboard navigation");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user