diff --git a/src/components/TimesheetNameInput/TimesheetNameInput.test.ts b/src/components/TimesheetNameInput/TimesheetNameInput.test.ts index fefd35a..c8b3d54 100644 --- a/src/components/TimesheetNameInput/TimesheetNameInput.test.ts +++ b/src/components/TimesheetNameInput/TimesheetNameInput.test.ts @@ -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"]); diff --git a/src/components/TimesheetNameInput/TimesheetNameInput.ts b/src/components/TimesheetNameInput/TimesheetNameInput.ts index f5e7eab..68669ad 100644 --- a/src/components/TimesheetNameInput/TimesheetNameInput.ts +++ b/src/components/TimesheetNameInput/TimesheetNameInput.ts @@ -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", diff --git a/src/components/TimesheetTable/TimesheetTable.ts b/src/components/TimesheetTable/TimesheetTable.ts index 8bdbb05..aa746a3 100644 --- a/src/components/TimesheetTable/TimesheetTable.ts +++ b/src/components/TimesheetTable/TimesheetTable.ts @@ -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); }