Skip to content

Commit

Permalink
feat(ui5-combobox): add full keyboard handling (#4494)
Browse files Browse the repository at this point in the history
The keyboard specification for the ComboBox is now fully implemented, with the addition of the following keys:

- `F4`, `ALT`+`UP`/`DOWN`- Toggles the picker.
- `PAGEDOWN` - Moves selection down by page size (10 items by default).
- `PAGEUP` - Moves selection up by page size (10 items by default).
- `HOME` - If focus is in the ComboBox, moves cursor at the beginning of text. If focus is in the picker, selects the first item.
- `END` - If focus is in the ComboBox, moves cursor at the end of text. If focus is in the picker, selects the last item.
  • Loading branch information
stbodurov committed Jan 14, 2022
1 parent 4e430d3 commit fd4bb50
Show file tree
Hide file tree
Showing 2 changed files with 131 additions and 20 deletions.
97 changes: 77 additions & 20 deletions packages/main/src/ComboBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
isEscape,
isTabNext,
isTabPrevious,
isPageUp,
isPageDown,
isHome,
isEnd,
} from "@ui5/webcomponents-base/dist/Keys.js";
import * as Filters from "./ComboBoxFilters.js";

Expand Down Expand Up @@ -326,22 +330,32 @@ const metadata = {
* The <code>ui5-combobox</code> component represents a drop-down menu with a list of the available options and a text input field to narrow down the options.
*
* It is commonly used to enable users to select an option from a predefined list.
*
* <h3>Structure</h3>
* The <code>ui5-combobox</code> consists of the following elements:
* <ul>
* <li> Input field - displays the selected option or a custom user entry. Users can type to narrow down the list or enter their own value.
* <li> Drop-down arrow - expands\collapses the option list.</li>
* <li> Option list - the list of available options.</li>
* </ul>
*
* <h3>Keyboard Handling</h3>
*
* The <code>ui5-combobox</code> provides advanced keyboard handling.
*
* <h4>Picker</h4>
* If the <code>ui5-combobox</code> is focused,
* you can open or close the drop-down by pressing <code>F4</code>, <code>ALT+UP</code> or <code>ALT+DOWN</code> keys.
* <br>
*
* <ul>
* <li>[F4], [ALT]+[UP], or [ALT]+[DOWN] - Toggles the picker.</li>
* <li>[ESC] - Closes the picker, if open. If closed, cancels changes and reverts the typed in value.</li>
* <li>[ENTER] or [RETURN] - If picker is open, takes over the currently selected item and closes it.</li>
* <li>[DOWN] - Selects the next matching item in the picker.</li>
* <li>[UP] - Selects the previous matching item in the picker.</li>
* <li>[PAGEDOWN] - Moves selection down by page size (10 items by default).</li>
* <li>[PAGEUP] - Moves selection up by page size (10 items by default). </li>
* <li>[HOME] - If focus is in the ComboBox, moves cursor at the beginning of text. If focus is in the picker, selects the first item.</li>
* <li>[END] - If focus is in the ComboBox, moves cursor at the end of text. If focus is in the picker, selects the last item.</li>
* </ul>
*
*
* <h3>ES6 Module Import</h3>
*
Expand Down Expand Up @@ -518,7 +532,7 @@ class ComboBox extends UI5Element {

_resetFilter() {
this._userTypedValue = null;
this.inner.setSelectionRange(0, 0); // Put the cursor at the end
this.inner.setSelectionRange(0, this.value.length);
this._filteredItems = this._filterItems("");
this._selectMatchingItem();
}
Expand Down Expand Up @@ -590,34 +604,30 @@ class ComboBox extends UI5Element {
});
}

async handleArrowKeyPress(event) {
if (this.readonly || !this._filteredItems.length) {
handleNavKeyPress(event) {
if (this.focused && (isHome(event) || isEnd(event)) && this.value) {
return;
}

const isOpen = this.open;
const isArrowDown = isDown(event);
const isArrowUp = isUp(event);
const currentItem = this._filteredItems.find(item => {
return isOpen ? item.focused : item.selected;
});
const indexOfItem = this._filteredItems.indexOf(currentItem);

event.preventDefault();

if ((this.focused === true && isArrowUp && isOpen) || (this._filteredItems.length - 1 === indexOfItem && isArrowDown)) {
if (this.focused && isOpen && (isUp(event) || isPageUp(event) || isPageDown(event))) {
return;
}

this._isKeyNavigation = true;

if (isArrowDown) {
this._handleArrowDown(event, indexOfItem);
if (this._filteredItems.length - 1 === indexOfItem && isDown(event)) {
return;
}

if (isArrowUp) {
this._handleArrowUp(event, indexOfItem);
}
this._isKeyNavigation = true;

this[`_handle${event.key}`](event, indexOfItem);
}

_handleItemNavigation(event, indexOfItem, isForward) {
Expand Down Expand Up @@ -708,17 +718,59 @@ class ComboBox extends UI5Element {
this._handleItemNavigation(event, --indexOfItem, false /* isForward */);
}

_handlePageUp(event, indexOfItem) {
const isProposedIndexValid = indexOfItem - ComboBox.SKIP_ITEMS_SIZE > -1;
indexOfItem = isProposedIndexValid ? indexOfItem - ComboBox.SKIP_ITEMS_SIZE : 0;
const shouldMoveForward = this._filteredItems[indexOfItem].isGroupItem && !this.open;

if (!isProposedIndexValid && this.hasValueStateText && this.open) {
this._clearFocus();
this._itemFocused = false;
this._isValueStateFocused = true;
return;
}

this._handleItemNavigation(event, indexOfItem, shouldMoveForward);
}

_handlePageDown(event, indexOfItem) {
const itemsLength = this._filteredItems.length;
const isProposedIndexValid = indexOfItem + ComboBox.SKIP_ITEMS_SIZE < itemsLength;

indexOfItem = isProposedIndexValid ? indexOfItem + ComboBox.SKIP_ITEMS_SIZE : itemsLength - 1;
const shouldMoveForward = this._filteredItems[indexOfItem].isGroupItem && !this.open;

this._handleItemNavigation(event, indexOfItem, shouldMoveForward);
}

_handleHome(event, indexOfItem) {
const shouldMoveForward = this._filteredItems[0].isGroupItem && !this.open;

if (this.hasValueStateText && this.open) {
this._clearFocus();
this._itemFocused = false;
this._isValueStateFocused = true;
return;
}

this._handleItemNavigation(event, indexOfItem = 0, shouldMoveForward);
}

_handleEnd(event, indexOfItem) {
this._handleItemNavigation(event, indexOfItem = this._filteredItems.length - 1, true /* isForward */);
}

_keyup(event) {
this._userTypedValue = this.value.substring(0, this.inner.selectionStart);
}

_keydown(event) {
const isArrowKey = isDown(event) || isUp(event);
const isNavKey = isDown(event) || isUp(event) || isPageUp(event) || isPageDown(event) || isHome(event) || isEnd(event);
this._autocomplete = !(isBackSpace(event) || isDelete(event));
this._isKeyNavigation = false;

if (isArrowKey) {
this.handleArrowKeyPress(event);
if (isNavKey && !this.readonly && this._filteredItems.length) {
this.handleNavKeyPress(event);
}

if (isEnter(event)) {
Expand Down Expand Up @@ -751,6 +803,9 @@ class ComboBox extends UI5Element {
this._itemFocused = true;
selectedItem.focused = true;
this.focused = false;
} else if (this.open && this._filteredItems.length) {
// If no item is selected, select the first one on "Show" (F4, Alt+Up/Down)
this._handleItemNavigation(event, 0, true /* isForward */);
} else {
this.focused = true;
}
Expand Down Expand Up @@ -1047,6 +1102,8 @@ class ComboBox extends UI5Element {
}
}

ComboBox.SKIP_ITEMS_SIZE = 10;

ComboBox.define();

export default ComboBox;
54 changes: 54 additions & 0 deletions packages/main/test/specs/ComboBox.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -759,4 +759,58 @@ describe("Keyboard navigation", async () => {

assert.strictEqual(await prevCombo.getProperty("focused"), true, "The previous combobox should be focused");
});

it ("Should select the corresponding item on home/pgup/pgdown/end", async () => {
await browser.url(`http://localhost:${PORT}/test-resources/pages/ComboBox.html`);

const comboBox = await browser.$("#combo2");
const input = await comboBox.shadow$("#ui5-combobox-input");
const pickerIcon = await comboBox.shadow$("[input-icon]");
const staticAreaItemClassName = await browser.getStaticAreaItemClassName("#combo2");
const respPopover = await browser.$(`.${staticAreaItemClassName}`).shadow$("ui5-responsive-popover");
let listItem;

// Opened picker
await pickerIcon.click();
await input.keys("ArrowDown");
await input.keys("ArrowDown");

await input.keys("Home");
listItem = await respPopover.$("ui5-list").$("ui5-li");
assert.strictEqual(await listItem.getProperty("focused"), true, "The first item should be focused on HOME");
assert.strictEqual(await comboBox.getProperty("focused"), false, "The ComboBox should not be focused");

await input.keys("End");
listItem = await respPopover.$("ui5-list").$$("ui5-li")[10];
assert.strictEqual(await listItem.getProperty("focused"), true, "The last item should be focused on END");

await input.keys("PageUp");
listItem = await respPopover.$("ui5-list").$("ui5-li");
assert.strictEqual(await listItem.getProperty("focused"), true, "The -10 item should be focused on PAGEUP");

await input.keys("PageDown");
listItem = await respPopover.$("ui5-list").$$("ui5-li")[10];
assert.strictEqual(await listItem.getProperty("focused"), true, "The +10 item should be focused on PAGEDOWN");

// Closed picker
await pickerIcon.click();

// Clearing typed in value to prevent default behavior of HOME
await comboBox.setProperty("value", "");

await input.keys("Home");
assert.strictEqual(await input.getProperty("value"), "Algeria", "The first item should be selected on HOME");

// Clearing typed in value to prevent default behavior of END
await comboBox.setProperty("value", "");

await input.keys("End");
assert.strictEqual(await input.getProperty("value"), "Chile", "The last item should be selected on END");

await input.keys("PageUp");
assert.strictEqual(await input.getProperty("value"), "Algeria", "The -10 item should be selected on PAGEUP");

await input.keys("PageDown");
assert.strictEqual(await input.getProperty("value"), "Chile", "The +10 item should be selected on PAGEDOWN");
});
});

0 comments on commit fd4bb50

Please sign in to comment.