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();
+ });
+ });
+});