From c2dce2ed187f3cef4cfe86bd72e0bbd315f37168 Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sat, 11 Apr 2026 13:40:39 +1200 Subject: [PATCH] tests: TimesheetNameInput remaining tests --- .../TimesheetNameInput.test.ts | 56 +++++++++++++++++++ .../TimesheetNameInput/TimesheetNameInput.ts | 50 +++++++++++------ 2 files changed, 88 insertions(+), 18 deletions(-) diff --git a/src/components/TimesheetNameInput/TimesheetNameInput.test.ts b/src/components/TimesheetNameInput/TimesheetNameInput.test.ts index 745e2a9..fefd35a 100644 --- a/src/components/TimesheetNameInput/TimesheetNameInput.test.ts +++ b/src/components/TimesheetNameInput/TimesheetNameInput.test.ts @@ -239,6 +239,10 @@ describe("TimesheetNameInput", () => { expect(component.getValue()).toBe("Test"); }); + it("should get an empty string attempting to retrieve the input value before load", () => { + expect(component.getValue()).toBe(""); + }); + it("should be able to reset the input value", () => { autocomplete.names.setState(["Test", "Test 1"]); component.load(); @@ -256,6 +260,10 @@ describe("TimesheetNameInput", () => { expect(inputEl.value).toBe(""); }); + it("resetting the input value before loading should do nothing", () => { + component.resetValue(); + }); + it("down arrow should be able to move the focused suggestion down", () => { autocomplete.names.setState(["Test", "Test 1"]); @@ -332,6 +340,24 @@ describe("TimesheetNameInput", () => { expect(onSelectSuggestion).toHaveBeenLastCalledWith("Test"); }); + it("enter should do nothing without any suggestions", () => { + 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.dispatchEvent( + new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }) + ); + + expect(onKeyDown).toHaveBeenCalledOnce(); + expect(onSelectSuggestion).not.toHaveBeenCalled(); + }); + it("escape should close the suggestions", () => { autocomplete.names.setState(["Test", "Other"]); @@ -396,6 +422,34 @@ describe("TimesheetNameInput", () => { expect(setSuggestionsOpen).toHaveBeenLastCalledWith(false); }); + it("clicking inside the input container should not close suggestions", () => { + document.body.appendChild(containerEl); + + const component = new TimesheetNameInput(containerEl, autocomplete); + + autocomplete.names.setState(["Test", "Other"]); + + const onClickOutside = vi.spyOn(component, "onClickOutside"); + 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(); + + component.wrapperEl!.dispatchEvent( + new MouseEvent("mousedown", { bubbles: true, cancelable: true }) + ); + + expect(onClickOutside).toHaveBeenCalledOnce(); + expect(setSuggestionsOpen).not.toHaveBeenLastCalledWith(false); + + document.body.removeChild(containerEl); + }); + it("touching outside the input container should close suggestions", () => { const component = new TimesheetNameInput(containerEl, autocomplete); @@ -434,6 +488,7 @@ describe("TimesheetNameInput", () => { ]); const onSelectSuggestion = vi.spyOn(component, "onSelectSuggestion"); + const onClickSuggestions = vi.spyOn(component, "onClickSuggestions"); component.load(); const inputEl = containerEl.querySelector(".timekeep-name")! as HTMLInputElement; @@ -451,6 +506,7 @@ describe("TimesheetNameInput", () => { suggestion.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })); expect(onSelectSuggestion).toHaveBeenCalledWith("Test"); + expect(onClickSuggestions).toHaveBeenCalled(); }); it("clicking events should be ignored when outside a suggestion", () => { diff --git a/src/components/TimesheetNameInput/TimesheetNameInput.ts b/src/components/TimesheetNameInput/TimesheetNameInput.ts index cfc926b..f5e7eab 100644 --- a/src/components/TimesheetNameInput/TimesheetNameInput.ts +++ b/src/components/TimesheetNameInput/TimesheetNameInput.ts @@ -137,8 +137,13 @@ export class TimesheetNameInput extends DomComponent { */ onClickSuggestions(event: MouseEvent) { const target = event.target; + + /* v8 ignore start -- @preserve */ if (!(target instanceof HTMLElement)) return; + /* v8 ignore stop -- @preserve */ + if (!target.id.startsWith("timekeepSuggestion-")) return; + const suggestion = target; const value = suggestion.getAttribute("value"); assert(value !== null, "Suggestion value should not be null"); @@ -167,13 +172,16 @@ export class TimesheetNameInput extends DomComponent { * * @param event The click / touch event */ + onClickOutside(event: MouseEvent | TouchEvent) { - if ( - !(event.target instanceof Node) || - (this.wrapperEl && !this.wrapperEl.contains(event.target)) - ) { - this.setSuggestionsOpen(false); - } + assert( + this.wrapperEl, + "Wrapper element must be defined for the onClickOutside event to fire" + ); + + if (this.wrapperEl.contains(event.target as Node | null)) return; + + this.setSuggestionsOpen(false); } /** @@ -194,7 +202,8 @@ export class TimesheetNameInput extends DomComponent { const suggestionsEl = this.#suggestionsEl; assert(suggestionsEl, "Suggestions element should be defined"); - const suggestionsOpen = !(suggestionsEl.hidden ?? false); + const suggestionsHidden = suggestionsEl.hidden; + const suggestionsOpen = !suggestionsHidden; if (!suggestionsOpen && event.key === "ArrowDown") { this.setSuggestionsOpen(true); @@ -224,14 +233,18 @@ export class TimesheetNameInput extends DomComponent { } case "Enter": { + event.preventDefault(); + + // Clamp focus within suggestion bounds before making decisions + this.clampSuggestionFocus(); + const focusIndex = this.#suggestionFocusIndex; - if (focusIndex >= 0) { - event.preventDefault(); - const suggestion = this.#suggestions[focusIndex]; - if (suggestion) { - this.onSelectSuggestion(suggestion.item); - } - } + if (focusIndex < 0) return; + + const suggestion = this.#suggestions[focusIndex]; + assert(suggestion, "Suggestion should always be within defined bounds"); + + this.onSelectSuggestion(suggestion.item); break; } @@ -337,10 +350,11 @@ export class TimesheetNameInput extends DomComponent { * @param value The suggestion value */ onSelectSuggestion(value: string) { - if (this.#inputEl) { - this.#inputEl.value = value; - } - + assert( + this.#inputEl, + "Input element should be defined for onSelectSuggestion to be called" + ); + this.#inputEl.value = value; this.setSuggestionsOpen(false); this.setSuggestionFocus(-1); }