feat: improvements to how name input keyboard keys are handled and don't

focus suggestions by default
This commit is contained in:
Jacobtread 2026-04-12 00:04:58 +12:00
parent 490e9102b8
commit 52b3c31dbd
3 changed files with 117 additions and 16 deletions

View file

@ -94,8 +94,8 @@ describe("TimesheetNameInput", () => {
expect(onDebouncedChange).toHaveBeenCalledOnce();
expect(getFilteredSuggestions).toHaveBeenCalledOnce();
// Selection focus should reset to the first suggestion
expect(setSuggestionFocus).toHaveBeenLastCalledWith(0);
// Selection focus should reset to nothing
expect(setSuggestionFocus).toHaveBeenLastCalledWith(-1);
// Suggestions should contain both items
const suggestions = containerEl.querySelectorAll(".timekeep-suggestion");
@ -122,8 +122,8 @@ describe("TimesheetNameInput", () => {
expect(onDebouncedChange).toHaveBeenCalledOnce();
expect(getFilteredSuggestions).toHaveBeenCalledOnce();
// Selection focus should reset to the first suggestion
expect(setSuggestionFocus).toHaveBeenLastCalledWith(0);
// Selection focus should reset to nothing
expect(setSuggestionFocus).toHaveBeenLastCalledWith(-1);
// Suggestions should contain just the matching item
const suggestions = containerEl.querySelectorAll(".timekeep-suggestion");
@ -282,14 +282,52 @@ describe("TimesheetNameInput", () => {
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" })
);
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" })
);
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" })
);
expect(onKeyDown).toHaveBeenCalledTimes(2);
expect(onKeyDown).toHaveBeenCalledTimes(3);
expect(onSelectSuggestion).toHaveBeenLastCalledWith("Test 1");
});
it("down arrow should open the suggestions when they are closed", () => {
autocomplete.names.setState(["Test", "Test 1"]);
const onKeyDown = vi.spyOn(component, "onKeyDown");
const onSelectSuggestion = vi.spyOn(component, "onSelectSuggestion");
const setSuggestionsOpen = vi.spyOn(component, "setSuggestionsOpen");
component.load();
const inputEl = containerEl.querySelector(".timekeep-name")! as HTMLInputElement;
expect(inputEl).not.toBeNull();
inputEl.value = "Test";
inputEl.focus();
inputEl.dispatchEvent(new Event("focus", { bubbles: true }));
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Escape" })
);
//
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" })
);
expect(setSuggestionsOpen).toHaveBeenLastCalledWith(true);
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" })
);
expect(onKeyDown).toHaveBeenCalledTimes(3);
expect(onSelectSuggestion).toHaveBeenLastCalledWith("Test");
});
it("up arrow should be able to move the focused suggestion up", () => {
autocomplete.names.setState(["Test", "Test 1"]);
@ -307,6 +345,9 @@ describe("TimesheetNameInput", () => {
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" })
);
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" })
);
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowUp" })
);
@ -314,7 +355,7 @@ describe("TimesheetNameInput", () => {
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" })
);
expect(onKeyDown).toHaveBeenCalledTimes(3);
expect(onKeyDown).toHaveBeenCalledTimes(4);
expect(onSelectSuggestion).toHaveBeenLastCalledWith("Test");
});
@ -332,14 +373,74 @@ describe("TimesheetNameInput", () => {
inputEl.value = "Test";
inputEl.focus();
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" })
);
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" })
);
expect(onKeyDown).toHaveBeenCalledOnce();
expect(onKeyDown).toHaveBeenCalledTimes(2);
expect(onSelectSuggestion).toHaveBeenLastCalledWith("Test");
});
it("enter should do nothing without a focused suggestion", () => {
autocomplete.names.setState(["Test", "Other"]);
const onKeyDown = vi.spyOn(component, "onKeyDown");
const onSelectSuggestion = vi.spyOn(component, "onSelectSuggestion");
component.load();
const inputEl = containerEl.querySelector(".timekeep-name")! as HTMLInputElement;
expect(inputEl).not.toBeNull();
inputEl.value = "Test";
inputEl.focus();
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" })
);
expect(onKeyDown).toHaveBeenCalledTimes(1);
expect(onSelectSuggestion).not.toHaveBeenCalled();
});
it("enter should do nothing when the suggestions box is closed", () => {
autocomplete.names.setState(["Test", "Test 1", "Other"]);
const onKeyDown = vi.spyOn(component, "onKeyDown");
const onSelectSuggestion = vi.spyOn(component, "onSelectSuggestion");
const setSuggestionsOpen = vi.spyOn(component, "setSuggestionsOpen");
const onClickOutside = vi.spyOn(component, "onClickOutside");
component.load();
const inputEl = containerEl.querySelector(".timekeep-name")! as HTMLInputElement;
expect(inputEl).not.toBeNull();
inputEl.value = "Test";
inputEl.focus();
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" })
);
document.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
expect(onClickOutside).toHaveBeenCalledOnce();
expect(setSuggestionsOpen).toHaveBeenLastCalledWith(false);
inputEl.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" })
);
expect(onKeyDown).toHaveBeenCalledTimes(2);
expect(onSelectSuggestion).not.toHaveBeenCalled();
});
it("enter should do nothing without any suggestions", () => {
autocomplete.names.setState(["Test", "Other"]);

View file

@ -158,12 +158,8 @@ export class TimesheetNameInput extends DomComponent {
const suggestions = this.getFilteredSuggestions();
this.#suggestions = suggestions;
this.updateSuggestionsOpen();
this.setSuggestionsOpen(true);
this.renderSuggestions();
if (suggestions.length > 0) {
this.setSuggestionFocus(0);
}
}
/**
@ -233,14 +229,17 @@ export class TimesheetNameInput extends DomComponent {
}
case "Enter": {
event.preventDefault();
// Clamp focus within suggestion bounds before making decisions
this.clampSuggestionFocus();
// Don't act if the user hasn't selected an item
const focusIndex = this.#suggestionFocusIndex;
if (focusIndex < 0) return;
// Don't act if the suggestions aren't open
if (!this.#suggestionsOpen) return;
event.preventDefault();
const suggestion = this.#suggestions[focusIndex];
assert(suggestion, "Suggestion should always be within defined bounds");
@ -250,6 +249,7 @@ export class TimesheetNameInput extends DomComponent {
case "Escape": {
this.setSuggestionsOpen(false);
this.setSuggestionFocus(-1);
break;
}
@ -266,7 +266,7 @@ export class TimesheetNameInput extends DomComponent {
if (this.#suggestions.length > 0) {
const focusIndex = this.#suggestionFocusIndex;
const newFocusIndex =
focusIndex === -1 ? 0 : Math.min(focusIndex, this.#suggestions.length - 1);
focusIndex === -1 ? -1 : Math.min(focusIndex, this.#suggestions.length - 1);
this.setSuggestionFocus(newFocusIndex);
} else {
@ -305,6 +305,7 @@ export class TimesheetNameInput extends DomComponent {
// Update the input aria-activedescendant for screen readers
if (index === -1) {
inputEl.removeAttribute("aria-activedescendant");
suggestionsEl.scrollTo({ top: 0 });
} else {
inputEl.setAttribute(
"aria-activedescendant",

View file

@ -108,7 +108,6 @@ export class TimesheetTable extends DomComponent {
clearRows() {
// Unload existing children and reset the rows list
for (const row of this.#rows) {
console.log(row);
this.removeChild(row);
}