From 5e7bc4ab851dba3d2e0b965f69e06cfeacec5674 Mon Sep 17 00:00:00 2001 From: luvi Date: Thu, 27 Apr 2023 08:02:45 +0000 Subject: [PATCH] [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) --- .../src/core/select_menu/select_menu.js | 22 +++- .../src/core/select_menu/select_menu.scss | 10 +- .../src/core/select_menu/select_menu.xml | 28 ++--- .../static/tests/core/select_menu_tests.js | 111 +++++++++++++++++- 4 files changed, 145 insertions(+), 26 deletions(-) diff --git a/addons/web/static/src/core/select_menu/select_menu.js b/addons/web/static/src/core/select_menu/select_menu.js index 82924d1b68e..da86db1ee3a 100644 --- a/addons/web/static/src/core/select_menu/select_menu.js +++ b/addons/web/static/src/core/select_menu/select_menu.js @@ -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(); } } diff --git a/addons/web/static/src/core/select_menu/select_menu.scss b/addons/web/static/src/core/select_menu/select_menu.scss index 0e54926f652..e88fb227452 100644 --- a/addons/web/static/src/core/select_menu/select_menu.scss +++ b/addons/web/static/src/core/select_menu/select_menu.scss @@ -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; diff --git a/addons/web/static/src/core/select_menu/select_menu.xml b/addons/web/static/src/core/select_menu/select_menu.xml index b0d949e80c4..c380b677dfe 100644 --- a/addons/web/static/src/core/select_menu/select_menu.xml +++ b/addons/web/static/src/core/select_menu/select_menu.xml @@ -30,18 +30,18 @@ -
- -
+ No result found @@ -61,9 +61,7 @@ -
- -
+ diff --git a/addons/web/static/tests/core/select_menu_tests.js b/addons/web/static/tests/core/select_menu_tests.js index a0559dbee06..87cbb346d7b 100644 --- a/addons/web/static/tests/core/select_menu_tests.js +++ b/addons/web/static/tests/core/select_menu_tests.js @@ -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) => { > Select something - + items selected @@ -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` + + `; + + 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` + + `; + + 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"); + } + ); });