diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..45cff3f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Tests +on: + pull_request: + branches: + - '**' + + # ALLOW MANUAL RUNS + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20.x' + - run: npm ci + - run: npm test + - run: npm run prepublishOnly diff --git a/CHANGELOG.md b/CHANGELOG.md index 83480f7..223848f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## UNPUBLISHED + +### Fixed + +- The mouse being over the popup when it's rendered no longer selects that value whilst typing + +### Changes + +- Developer dependency bumps (no user-facing changes) +- Added CI based testing for every PR + ## [1.0.1] - 2023-11-14 [Compare to previous release][comp:1.0.1] diff --git a/src/index.ts b/src/index.ts index 95ef633..28cf6d6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +'use strict'; + import { AutocompleteEventFunction as EventFunction, CloseEventData, @@ -29,6 +31,7 @@ export class Autocomplete { lastTerm: string; valueStore?: string; focusValue?: string; + focusPoint?: [number, number]; } > = {}; @@ -168,8 +171,14 @@ export class Autocomplete { this._removeFocus(data.ul); // Focus on the new one - const liEl = ev.target, - newVal = liEl.dataset.value || liEl.innerText; + const liEl = (ev.target).closest('li'); + + if (!liEl) { + return; + } + + const newVal = liEl.dataset.value || liEl.innerText; + liEl.classList.add('focused'); // Update the input value and store @@ -200,10 +209,10 @@ export class Autocomplete { if (typeof (data.item as { link?: string }).link === 'string') { window.location.href = (data.item as { link: string }).link; } else { - const liEl = ev.target; + const liEl = (ev.target).closest('li'); // Set input value - data.input.value = liEl.dataset.value ?? liEl.innerText; + data.input.value = liEl?.dataset.value ?? liEl?.innerText ?? ''; this._stateData[data.ul.id].valueStore = data.input.value; this._clearFocusStore(data.ul.id); @@ -229,7 +238,7 @@ export class Autocomplete { this.options.onOpen?.(ev, data); - const tL = position({ + const { top, left } = position({ target: data.ul, anchor: ev.target, my: this.options.position.my, @@ -237,12 +246,28 @@ export class Autocomplete { collision: this.options.position.collision, }); - data.ul.style.top = tL.top; - data.ul.style.left = tL.left; + data.ul.style.top = top; + data.ul.style.left = left; data.ul.hidden = false; if (this.options.autoFocus) { data.ul.children[0]?.dispatchEvent(new Event('focus')); + } else { + this._stateData[data.ul.id].focusPoint = [-1, -1]; + + // If they aren't already hovering over it, remove the focusPoint + // so we can trigger mouseover events immediately + setTimeout(() => { + const focusPoint = this._stateData[data.ul.id].focusPoint; + + if ( + focusPoint && + focusPoint[0] === -1 && + focusPoint[1] === -1 + ) { + this._stateData[data.ul.id].focusPoint = undefined; + } + }, 333); } this._traceLog('Opened menu', `Menu id: ${data.ul.id}`); @@ -279,6 +304,7 @@ export class Autocomplete { target.value = vS; this._stateData[data.ul.id].valueStore = undefined; + this._stateData[data.ul.id].focusValue = undefined; this._infoLog('Reverted input', `Input ac-id: ${data.ul.id}`); } @@ -306,8 +332,8 @@ export class Autocomplete { ) ).json() as Promise[]>) : typeof this.options.source === 'function' - ? this.options.source({ term: data.term }) - : this.options.source, + ? this.options.source({ term: data.term }) + : this.options.source, }); } catch { return; @@ -491,9 +517,14 @@ export class Autocomplete { }; private _itemClickEvent = (ev: MouseEvent) => { - const li = ev.target, - ul = li.parentElement, - id = ul.id, + const li = (ev.target).closest('li'), + ul = li?.closest('ul'); + + if (!ul || !li) { + return; + } + + const id = ul.id, item = this._stateData[id].data[Array.from(ul.children).indexOf(li)], input = ( @@ -505,19 +536,53 @@ export class Autocomplete { this.itemSelect(ev, { ul, item, input }); }; - private _itemFocusEvent = (ev: FocusEvent) => { - const li = ev.target, - ul = li.parentElement, - id = ul.id, + private _itemFocusEvent = (ev: FocusEvent | MouseEvent) => { + const li = (ev.target).closest('li'), + ul = li?.closest('ul'); + + if (!ul || !li) { + return; + } + + const id = ul.id, item = this._stateData[id].data[Array.from(ul.children).indexOf(li)], input = ( document.querySelector(`input[data-ac-id='${id}']`) - ); + ), + that = this; + + if (ev instanceof MouseEvent && this._stateData[id].focusPoint) { + const [x, y] = this._stateData[id].focusPoint; + + if (x === -1 && y === -1) { + this._stateData[id].focusPoint = [ev.clientX, ev.clientY]; + li.addEventListener('mousemove', handlePopHover); + + return; + } + + this._stateData[id].focusPoint = undefined; + } this._traceLog('Menu item focused', `Item summary: ${li.innerText}`); this.itemFocus(ev, { ul, item, input }); + + function handlePopHover(this: HTMLLIElement, subEv: MouseEvent) { + const focusPoint = that._stateData[id].focusPoint; + + if ( + focusPoint === undefined || + Math.abs(focusPoint[0] - subEv.clientX) > 5 || + Math.abs(focusPoint[1] - subEv.clientY) > 5 + ) { + that._stateData[id].focusPoint = undefined; + li!.removeEventListener('mousemove', handlePopHover); + + li!.dispatchEvent(new MouseEvent('mouseover', subEv)); + } + } }; private _removeFocus = (ul: HTMLUListElement) => { @@ -555,25 +620,25 @@ export class Autocomplete { private _debrottle(func: F) { const that = this; let calledAgain: boolean; - let dTimer: NodeJS.Timer | number | undefined; + let dTimer: ReturnType | undefined; return function (this: ThisParameterType, ...args: Parameters) { if (dTimer) { calledAgain = true; } else { - const context = this; + const subThat = this; dTimer = setTimeout(() => { if (calledAgain) { calledAgain = false; - func.apply(context, args); + func.apply(subThat, args); } dTimer = undefined; }, that.options.delay); - func.apply(context, args); + func.apply(subThat, args); } }; } diff --git a/tests/mouseover.test.ts b/tests/mouseover.test.ts new file mode 100644 index 0000000..8d848fc --- /dev/null +++ b/tests/mouseover.test.ts @@ -0,0 +1,88 @@ +import { Autocomplete, AutocompleteStatus } from '../src/index'; + +jest.useFakeTimers(); + +describe('Mouseover Tests', () => { + let inputEL: HTMLInputElement, autocomplete: Autocomplete; + + describe('Test environment:-', () => { + it('has added element', () => { + inputEL = document.createElement('input'); + + inputEL.classList.add('test'); + inputEL = document.body.insertAdjacentElement( + 'beforeend', + inputEL, + ) as HTMLInputElement; + + expect(inputEL).not.toBeNull(); + }); + + it('has created autocomplete', () => { + autocomplete = new Autocomplete('.test', { + source: [ + { label: 'First label', value: 'First Value' }, + { label: 'Second label', value: 'Second Value' }, + { label: 'Third label', value: 'Third Value' }, + { label: 'Final label', value: 'Final Value' }, + ], + onOpen: (e, data) => { + data.ul.style.width = `${ + (e.target as HTMLInputElement).width + }px`; + }, + }); + + expect(autocomplete).not.toBeNull(); + }); + + it('has initial state of "stopped"', () => + expect(autocomplete.status).toBe(AutocompleteStatus.Stopped)); + + it('"start" should not throw', () => + expect(autocomplete.start).not.toThrow()); + + it('now has "started" state', () => + expect(autocomplete.status).toBe(AutocompleteStatus.Started)); + }); + + describe('Mouse over', () => { + beforeEach(() => { + inputEL.dispatchEvent(new Event('focusout')); + jest.advanceTimersByTime(251); + }); + + it('popping up under mouse should not change input', () => { + inputEL.value = 'Test Value'; + inputEL.dispatchEvent(new Event('change')); + + const ul = + (document.getElementById( + inputEL.dataset.acId ?? '', + ) as HTMLUListElement | null) ?? document.createElement('ul'); + + ul.children[0].dispatchEvent(new Event('mouseover')); + + jest.advanceTimersByTime(1); + + const point: [number, number] | undefined = + //@ts-ignore + autocomplete._stateData[inputEL.dataset.acId].focusPoint; + + expect(point).toBeDefined(); + }); + + it('no initial mouseover should clear the focusPoint', () => { + inputEL.value = 'Test Value'; + inputEL.dispatchEvent(new Event('change')); + + jest.advanceTimersByTime(334); + + const point: [number, number] | undefined = + //@ts-ignore + autocomplete._stateData[inputEL.dataset.acId].focusPoint; + + expect(point).toBeUndefined(); + }); + }); +});