[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:
luvi
2023-04-28 06:56:53 +02:00
parent 0767e690f6
commit 5e7bc4ab85
4 changed files with 145 additions and 26 deletions
@@ -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");
}
);
});