Skip to content

Commit

Permalink
Added keyboard support when navigating suggested usernames (#1122)
Browse files Browse the repository at this point in the history
## Problem

In #1022 the username auto-suggest feature was introduced, but the
dropdown with usernames didn't support keyboard navigation.

This PR aims to solve this, It introduces navigation with arrow keys (up
and down) and sets the value on the press of Enter.

fixes #1070 

## Solution

Now checking for keyboard events on the username input field and on the
arrow presses changing the index of the selected menu item, highlighting
the appropriate one. On the press of Enter, again using the current
value of index, the text input is populated with a value from the
usernames array.
  • Loading branch information
dgdavid authored May 2, 2024
2 parents 7e43d58 + 3b0ab83 commit 13ddc2e
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 4 deletions.
6 changes: 6 additions & 0 deletions web/package/cockpit-agama.changes
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-------------------------------------------------------------------
Thu May 02 11:07:23 UTC 2024 - Balsa Asanovic <[email protected]>

- Added keyboard support for navigating dropdown
of suggested usernames. (gh#openSUSE/agama#1122).

-------------------------------------------------------------------
Thu Apr 25 15:04:05 UTC 2024 - José Iván López González <[email protected]>

Expand Down
49 changes: 45 additions & 4 deletions web/src/components/users/FirstUser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* find current contact information at www.suse.com.
*/

import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";

import { _ } from "~/i18n";
import { useCancellablePromise } from "~/utils";
Expand Down Expand Up @@ -82,7 +82,7 @@ const UserData = ({ user, actions }) => {
);
};

const UsernameSuggestions = ({ entries, onSelect, setInsideDropDown }) => {
const UsernameSuggestions = ({ entries, onSelect, setInsideDropDown, focusedIndex = -1 }) => {
return (
<Menu
aria-label={_("Username suggestion dropdown")}
Expand All @@ -96,6 +96,7 @@ const UsernameSuggestions = ({ entries, onSelect, setInsideDropDown }) => {
<MenuItem
key={index}
itemId={index}
isFocused={focusedIndex === index}
onClick={() => onSelect(suggestion)}
>
{ /* TRANSLATORS: dropdown username suggestions */}
Expand Down Expand Up @@ -131,6 +132,9 @@ export default function FirstUser() {
const [isSettingPassword, setIsSettingPassword] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const [insideDropDown, setInsideDropDown] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(-1);
const [suggestions, setSuggestions] = useState([]);
const usernameInputRef = useRef();

useEffect(() => {
cancellablePromise(client.users.getUser()).then(userValues => {
Expand Down Expand Up @@ -220,9 +224,43 @@ export default function FirstUser() {
const submitDisable = formValues.userName === "" || (isSettingPassword && !usingValidPassword);

const displaySuggestions = !formValues.userName && formValues.fullName && showSuggestions;
useEffect(() => {
if (displaySuggestions) {
setFocusedIndex(-1);
setSuggestions(suggestUsernames(formValues.fullName));
}
}, [displaySuggestions, formValues.fullName]);

const onSuggestionSelected = (suggestion) => {
setInsideDropDown(false);
setFormValues({ ...formValues, userName: suggestion });
usernameInputRef.current?.focus();
};

const handleKeyDown = (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault(); // Prevent page scrolling
if (suggestions.length > 0) setShowSuggestions(true);
setFocusedIndex((prevIndex) => (prevIndex + 1) % suggestions.length);
break;
case 'ArrowUp':
event.preventDefault(); // Prevent page scrolling
if (suggestions.length > 0) setShowSuggestions(true);
setFocusedIndex((prevIndex) => (prevIndex - (prevIndex === -1 ? 0 : 1) + suggestions.length) % suggestions.length);
break;
case 'Enter':
if (focusedIndex >= 0) {
onSuggestionSelected(suggestions[focusedIndex]);
}
break;
case 'Escape':
case 'Tab':
setShowSuggestions(false);
break;
default:
break;
}
};

if (isLoading) return <Skeleton />;
Expand Down Expand Up @@ -266,18 +304,21 @@ export default function FirstUser() {
id="userName"
name="userName"
aria-label={_("Username")}
ref={usernameInputRef}
value={formValues.userName}
label={_("Username")}
isRequired
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<If
condition={displaySuggestions}
condition={displaySuggestions && suggestions.length > 0}
then={
<UsernameSuggestions
entries={suggestUsernames(formValues.fullName)}
entries={suggestions}
onSelect={onSuggestionSelected}
setInsideDropDown={setInsideDropDown}
focusedIndex={focusedIndex}
/>
}
/>
Expand Down
111 changes: 111 additions & 0 deletions web/src/components/users/FirstUser.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ let setUserFn = jest.fn().mockResolvedValue(setUserResult);
const removeUserFn = jest.fn();
let onUsersChangeFn = jest.fn();

const openUserForm = async () => {
const { user } = installerRender(<FirstUser />);
await screen.findByText("No user defined yet.");
const button = await screen.findByRole("button", { name: "Define a user now" });
await user.click(button);
const dialog = await screen.findByRole("dialog");

return { user, dialog };
};

beforeEach(() => {
user = emptyUser;
createClient.mockImplementation(() => {
Expand Down Expand Up @@ -276,3 +286,104 @@ describe("when the user has been modified", () => {
screen.getByText("ytm");
});
});

describe("username suggestions", () => {
it("shows suggestions when full name is given and username gets focus", async () => {
const { user, dialog } = await openUserForm();

const fullNameInput = within(dialog).getByLabelText("Full name");
await user.type(fullNameInput, "Jane Doe");

await user.tab();

const menuItems = screen.getAllByText("Use suggested username");
expect(menuItems.length).toBe(4);
});

it("hides suggestions when username loses focus", async () => {
const { user, dialog } = await openUserForm();

const fullNameInput = within(dialog).getByLabelText("Full name");
await user.type(fullNameInput, "Jane Doe");

await user.tab();

let menuItems = screen.getAllByText("Use suggested username");
expect(menuItems.length).toBe(4);

await user.tab();

menuItems = screen.queryAllByText("Use suggested username");
expect(menuItems.length).toBe(0);
});

it("does not show suggestions when full name is not given", async () => {
const { user, dialog } = await openUserForm();

const fullNameInput = within(dialog).getByLabelText("Full name");
fullNameInput.focus();

await user.tab();

const menuItems = screen.queryAllByText("Use suggested username");
expect(menuItems.length).toBe(0);
});

it("hides suggestions if user types something", async () => {
const { user, dialog } = await openUserForm();

const fullNameInput = within(dialog).getByLabelText("Full name");
await user.type(fullNameInput, "Jane Doe");

await user.tab();

// confirming that we have suggestions
let menuItems = screen.queryAllByText("Use suggested username");
expect(menuItems.length).toBe(4);

const usernameInput = within(dialog).getByLabelText("Username");
// the user now types something
await user.type(usernameInput, "John Smith");

// checking if suggestions are gone
menuItems = screen.queryAllByText("Use suggested username");
expect(menuItems.length).toBe(0);
});

it("fills username input with chosen suggestion", async () => {
const { user, dialog } = await openUserForm();

const fullNameInput = within(dialog).getByLabelText("Full name");
await user.type(fullNameInput, "Will Power");

await user.tab();

const menuItem = screen.getByText('willpower');
const usernameInput = within(dialog).getByLabelText("Username");

await user.click(menuItem);

expect(usernameInput).toHaveFocus();
expect(usernameInput.value).toBe("willpower");
});

it("fills username input with chosen suggestion using keyboard for selection", async () => {
const { user, dialog } = await openUserForm();

const fullNameInput = within(dialog).getByLabelText("Full name");
await user.type(fullNameInput, "Jane Doe");

await user.tab();

const menuItems = screen.getAllByRole("menuitem");
const menuItemTwo = menuItems[1].textContent.replace("Use suggested username ", "");

await user.keyboard("{ArrowDown}");
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");

const usernameInput = within(dialog).getByLabelText("Username");
expect(usernameInput).toHaveFocus();
expect(usernameInput.value).toBe(menuItemTwo);
});
});

0 comments on commit 13ddc2e

Please sign in to comment.