2 files changed
+278
-30
No files matched your search
+214
-10
@@ -1,10 +1,15 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { applyHoverSearchHighlights, clearHoverSearchHighlights, createHoverSearch, findHoverSearchMatches } from "@/lib/editor/sqlHoverSearch";
|
||||
|
||||
const DDL = ["create table `orders` (", " `id` bigint not null,", " `customer_order_status` varchar(32) null,", " `USER_ID` bigint null,", " `amount` decimal(10,2) null", ");"].join("\n");
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
describe("findHoverSearchMatches", () => {
|
||||
it("returns no matches for an empty or whitespace query", () => {
|
||||
expect(findHoverSearchMatches(DDL, "")).toEqual([]);
|
||||
@@ -93,12 +98,25 @@ describe("applyHoverSearchHighlights", () => {
|
||||
expect(container.querySelector("mark")).toBeNull();
|
||||
expect(container.textContent).toBe(DDL);
|
||||
});
|
||||
|
||||
it("groups fragments of one logical match across syntax-highlight spans", () => {
|
||||
const container = document.createElement("div");
|
||||
container.innerHTML = "<span>order</span><span>_id</span> + <span>order_id</span>";
|
||||
const originalText = container.textContent!;
|
||||
const marks = applyHoverSearchHighlights(container, findHoverSearchMatches(originalText, "order_id"));
|
||||
|
||||
expect(marks.map((mark) => mark.getAttribute("data-sql-hover-search-match-index"))).toEqual(["0", "0", "1"]);
|
||||
expect(marks.map((mark) => mark.hasAttribute("data-sql-hover-search-active"))).toEqual([true, true, false]);
|
||||
expect(container.querySelectorAll("span")).toHaveLength(3);
|
||||
expect(container.textContent).toBe(originalText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createHoverSearch", () => {
|
||||
function setup() {
|
||||
function setup(html?: string) {
|
||||
const target = document.createElement("div");
|
||||
target.textContent = DDL;
|
||||
if (html === undefined) target.textContent = DDL;
|
||||
else target.innerHTML = html;
|
||||
const controller = createHoverSearch({
|
||||
target,
|
||||
originalHtml: target.innerHTML,
|
||||
@@ -106,7 +124,19 @@ describe("createHoverSearch", () => {
|
||||
noResultLabel: "No matching columns",
|
||||
});
|
||||
const input = controller.element.querySelector<HTMLInputElement>('[data-sql-hover-search-input="true"]')!;
|
||||
return { target, controller, input };
|
||||
const count = controller.element.querySelector<HTMLElement>('[data-sql-hover-search-count="true"]')!;
|
||||
return { target, controller, input, count };
|
||||
}
|
||||
|
||||
function pressKey(input: HTMLInputElement, key: string, options: KeyboardEventInit = {}) {
|
||||
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...options });
|
||||
input.dispatchEvent(event);
|
||||
input.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true, ...options }));
|
||||
return event;
|
||||
}
|
||||
|
||||
function activeMarks(target: HTMLElement) {
|
||||
return [...target.querySelectorAll<HTMLElement>('[data-sql-hover-search-active="true"]')];
|
||||
}
|
||||
|
||||
it("highlights correctly when line breaks render as <br> elements", () => {
|
||||
@@ -176,19 +206,19 @@ describe("createHoverSearch", () => {
|
||||
});
|
||||
|
||||
it("stops keydown propagation so the editor keymap never sees typing", () => {
|
||||
const { input } = setup();
|
||||
const { controller, input } = setup();
|
||||
let leaked = false;
|
||||
document.addEventListener("keydown", () => (leaked = true));
|
||||
controller.element.addEventListener("keydown", () => (leaked = true));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
expect(leaked).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the query on Escape but keeps propagation stopped (tooltip stays open)", () => {
|
||||
const { target, input } = setup();
|
||||
const { target, controller, input } = setup();
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
let leaked = false;
|
||||
document.addEventListener("keydown", () => (leaked = true));
|
||||
controller.element.addEventListener("keydown", () => (leaked = true));
|
||||
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
expect(input.value).toBe("");
|
||||
@@ -197,9 +227,9 @@ describe("createHoverSearch", () => {
|
||||
});
|
||||
|
||||
it("keeps pointerdown from bubbling to the editor (click focuses the input)", () => {
|
||||
const { input } = setup();
|
||||
const { controller, input } = setup();
|
||||
let leaked = false;
|
||||
document.addEventListener("pointerdown", () => (leaked = true));
|
||||
controller.element.addEventListener("pointerdown", () => (leaked = true));
|
||||
input.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
expect(leaked).toBe(false);
|
||||
});
|
||||
@@ -212,4 +242,178 @@ describe("createHoverSearch", () => {
|
||||
// No re-render after destroy.
|
||||
expect(target.querySelector("mark")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the current match count and cycles forward on Enter without rerendering", () => {
|
||||
const { target, controller, input, count } = setup("insurance_class + insurance_class AS insurance_class");
|
||||
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
|
||||
document.body.append(controller.element, target);
|
||||
input.focus();
|
||||
input.value = "insurance_class";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
const marks = [...target.querySelectorAll("mark")];
|
||||
|
||||
expect(count.hidden).toBe(false);
|
||||
expect(count.textContent).toBe("1 / 3");
|
||||
expect(count.getAttribute("role")).toBe("status");
|
||||
expect(count.getAttribute("aria-live")).toBe("polite");
|
||||
expect(activeMarks(target)).toEqual([marks[0]]);
|
||||
expect(scroll).toHaveBeenCalledTimes(1);
|
||||
|
||||
for (const index of [1, 2, 0]) {
|
||||
expect(pressKey(input, "Enter").defaultPrevented).toBe(true);
|
||||
expect(count.textContent).toBe(`${index + 1} / 3`);
|
||||
expect(activeMarks(target)).toEqual([marks[index]]);
|
||||
expect(scroll.mock.instances.at(-1)).toBe(marks[index]);
|
||||
}
|
||||
|
||||
expect(scroll).toHaveBeenCalledTimes(4);
|
||||
expect(target.querySelector("mark")).toBe(marks[0]);
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it("cycles backward on Shift+Enter and handles one match", () => {
|
||||
const { target, input, count } = setup("id + id + id");
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
|
||||
pressKey(input, "Enter", { shiftKey: true });
|
||||
expect(count.textContent).toBe("3 / 3");
|
||||
pressKey(input, "Enter", { shiftKey: true });
|
||||
expect(count.textContent).toBe("2 / 3");
|
||||
|
||||
input.value = "id + id + id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
pressKey(input, "Enter");
|
||||
pressKey(input, "Enter", { shiftKey: true });
|
||||
expect(count.textContent).toBe("1 / 1");
|
||||
expect(activeMarks(target)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("counts cross-span matches once and navigates every fragment together", () => {
|
||||
const { target, input, count } = setup("<span>order</span><span>_id</span><br><span>order_id</span>");
|
||||
input.value = "order_id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
const marks = [...target.querySelectorAll("mark")];
|
||||
|
||||
expect(marks).toHaveLength(3);
|
||||
expect(count.textContent).toBe("1 / 2");
|
||||
expect(activeMarks(target)).toEqual([marks[0], marks[1]]);
|
||||
pressKey(input, "Enter");
|
||||
expect(count.textContent).toBe("2 / 2");
|
||||
expect(activeMarks(target)).toEqual([marks[2]]);
|
||||
pressKey(input, "Enter");
|
||||
expect(activeMarks(target)).toEqual([marks[0], marks[1]]);
|
||||
expect(target.querySelectorAll("span")).toHaveLength(3);
|
||||
expect(target.querySelectorAll("br")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("resets the active match when the query changes and hides the count on Escape", () => {
|
||||
const { target, controller, input, count } = setup();
|
||||
expect(count.hidden).toBe(true);
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
pressKey(input, "Enter");
|
||||
expect(count.textContent).toBe("2 / 2");
|
||||
|
||||
input.value = "order";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
expect(count.textContent).toBe("1 / 2");
|
||||
expect(activeMarks(target)[0]).toBe(target.querySelector("mark"));
|
||||
pressKey(input, "Escape");
|
||||
expect(input.value).toBe("");
|
||||
expect(count.hidden).toBe(true);
|
||||
expect(count.textContent).toBe("");
|
||||
expect(controller.status.hidden).toBe(true);
|
||||
expect(target.textContent).toBe(DDL);
|
||||
expect(target.querySelector("mark")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows zero matches without scrolling and ignores navigation for blank queries", () => {
|
||||
const { target, controller, input, count } = setup();
|
||||
const scroll = vi.spyOn(HTMLElement.prototype, "scrollIntoView");
|
||||
input.value = "missing";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
pressKey(input, "Enter");
|
||||
pressKey(input, "Enter", { shiftKey: true });
|
||||
expect(count.hidden).toBe(false);
|
||||
expect(count.textContent).toBe("0 / 0");
|
||||
expect(controller.status.hidden).toBe(false);
|
||||
expect(activeMarks(target)).toHaveLength(0);
|
||||
|
||||
input.value = " ";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
pressKey(input, "Enter");
|
||||
expect(count.hidden).toBe(true);
|
||||
expect(controller.status.hidden).toBe(true);
|
||||
expect(scroll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["Enter", "Escape"])("preserves %s while the keyboard event is composing", (key) => {
|
||||
const { target, input } = setup("id + id");
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
const firstMatch = activeMarks(target)[0];
|
||||
|
||||
const event = pressKey(input, key, { isComposing: true });
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(input.value).toBe("id");
|
||||
expect(activeMarks(target)).toEqual([firstMatch]);
|
||||
});
|
||||
|
||||
it("uses composition lifecycle events when a key event omits isComposing", () => {
|
||||
const { target, input, count } = setup("id + id");
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
const firstMatch = activeMarks(target)[0];
|
||||
input.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
|
||||
|
||||
expect(pressKey(input, "Enter").defaultPrevented).toBe(false);
|
||||
expect(pressKey(input, "Escape").defaultPrevented).toBe(false);
|
||||
expect(input.value).toBe("id");
|
||||
expect(activeMarks(target)).toEqual([firstMatch]);
|
||||
|
||||
input.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true }));
|
||||
pressKey(input, "Enter");
|
||||
expect(count.textContent).toBe("2 / 2");
|
||||
});
|
||||
|
||||
it("ignores the legacy IME key code and does not clear on Escape keyup", () => {
|
||||
const { target, input } = setup("id + id");
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
const firstMatch = activeMarks(target)[0];
|
||||
|
||||
expect(pressKey(input, "Enter", { keyCode: 229 }).defaultPrevented).toBe(false);
|
||||
input.dispatchEvent(new KeyboardEvent("keyup", { key: "Escape", bubbles: true }));
|
||||
expect(input.value).toBe("id");
|
||||
expect(activeMarks(target)).toEqual([firstMatch]);
|
||||
});
|
||||
|
||||
it.each([{ ctrlKey: true }, { metaKey: true }, { altKey: true }])("does not navigate or leak modified Enter with %j", (modifiers) => {
|
||||
const { target, controller, input } = setup("id + id");
|
||||
const keydown = vi.fn();
|
||||
const keyup = vi.fn();
|
||||
controller.element.addEventListener("keydown", keydown);
|
||||
controller.element.addEventListener("keyup", keyup);
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
const firstMatch = activeMarks(target)[0];
|
||||
|
||||
pressKey(input, "Enter", modifiers);
|
||||
expect(activeMarks(target)).toEqual([firstMatch]);
|
||||
expect(keydown).not.toHaveBeenCalled();
|
||||
expect(keyup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops navigation after destroy", () => {
|
||||
const { target, controller, input, count } = setup("id + id");
|
||||
input.value = "id";
|
||||
input.dispatchEvent(new Event("input"));
|
||||
const firstMatch = activeMarks(target)[0];
|
||||
controller.destroy();
|
||||
|
||||
expect(pressKey(input, "Enter").defaultPrevented).toBe(false);
|
||||
expect(count.textContent).toBe("1 / 2");
|
||||
expect(activeMarks(target)).toEqual([firstMatch]);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
const MATCH_ATTRIBUTE = "data-sql-hover-search-match";
|
||||
const MATCH_INDEX_ATTRIBUTE = "data-sql-hover-search-match-index";
|
||||
const ACTIVE_ATTRIBUTE = "data-sql-hover-search-active";
|
||||
|
||||
export interface HoverSearchMatch {
|
||||
@@ -53,8 +54,8 @@ export function findHoverSearchMatches(text: string, query: string): HoverSearch
|
||||
* container's `textContent`); call {@link clearHoverSearchHighlights} (or
|
||||
* restore the original HTML) before re-applying.
|
||||
*
|
||||
* Returns the created `<mark>` elements in document order; the first is tagged
|
||||
* as the active match so callers can scroll it into view.
|
||||
* Returns the created `<mark>` elements in document order, grouped by match
|
||||
* index. Every fragment of the first match is tagged active.
|
||||
*/
|
||||
export function applyHoverSearchHighlights(container: HTMLElement, matches: HoverSearchMatch[]): HTMLElement[] {
|
||||
if (matches.length === 0) return [];
|
||||
@@ -71,19 +72,26 @@ export function applyHoverSearchHighlights(container: HTMLElement, matches: Hove
|
||||
}
|
||||
|
||||
const marks: HTMLElement[] = [];
|
||||
let firstMatchIndex = 0;
|
||||
for (const { node, start, end } of textNodes) {
|
||||
// Ranges (clamped to this node) that overlap the node, in node-local coords.
|
||||
const segments = matches.filter((match) => match.start < end && match.end > start).map((match) => ({ from: Math.max(match.start, start) - start, to: Math.min(match.end, end) - start }));
|
||||
if (segments.length === 0) continue;
|
||||
while (firstMatchIndex < matches.length && matches[firstMatchIndex].end <= start) firstMatchIndex++;
|
||||
if (firstMatchIndex === matches.length) break;
|
||||
if (start === end || matches[firstMatchIndex].start >= end) continue;
|
||||
|
||||
const data = node.data;
|
||||
const fragment = document.createDocumentFragment();
|
||||
let cursor = 0;
|
||||
for (const { from, to } of segments) {
|
||||
for (let matchIndex = firstMatchIndex; matchIndex < matches.length && matches[matchIndex].start < end; matchIndex++) {
|
||||
const match = matches[matchIndex];
|
||||
const from = Math.max(match.start, start) - start;
|
||||
const to = Math.min(match.end, end) - start;
|
||||
if (from > cursor) fragment.appendChild(document.createTextNode(data.slice(cursor, from)));
|
||||
const mark = document.createElement("mark");
|
||||
mark.setAttribute(MATCH_ATTRIBUTE, "true");
|
||||
mark.className = "rounded-[2px] bg-yellow-300/70 px-px text-inherit dark:bg-yellow-500/40";
|
||||
mark.setAttribute(MATCH_INDEX_ATTRIBUTE, String(matchIndex));
|
||||
if (matchIndex === 0) mark.setAttribute(ACTIVE_ATTRIBUTE, "true");
|
||||
mark.className =
|
||||
"rounded-[2px] bg-yellow-300/70 px-px text-inherit dark:bg-yellow-500/40 data-[sql-hover-search-active=true]:bg-yellow-400/90 data-[sql-hover-search-active=true]:outline data-[sql-hover-search-active=true]:outline-1 data-[sql-hover-search-active=true]:outline-yellow-600 dark:data-[sql-hover-search-active=true]:bg-yellow-500/70 dark:data-[sql-hover-search-active=true]:outline-yellow-400";
|
||||
mark.textContent = data.slice(from, to);
|
||||
fragment.appendChild(mark);
|
||||
marks.push(mark);
|
||||
@@ -93,7 +101,6 @@ export function applyHoverSearchHighlights(container: HTMLElement, matches: Hove
|
||||
node.replaceWith(fragment);
|
||||
}
|
||||
|
||||
marks[0]?.setAttribute(ACTIVE_ATTRIBUTE, "true");
|
||||
return marks;
|
||||
}
|
||||
|
||||
@@ -140,7 +147,7 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
|
||||
|
||||
const element = document.createElement("div");
|
||||
element.dataset.sqlHoverSearch = "true";
|
||||
element.className = "mt-2 flex-none";
|
||||
element.className = "mt-2 flex flex-none items-center gap-2";
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
@@ -149,18 +156,41 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
|
||||
input.setAttribute("aria-label", placeholder);
|
||||
input.spellcheck = false;
|
||||
input.autocomplete = "off";
|
||||
input.className = "w-full rounded border border-border/60 bg-background px-2 py-1 text-[11px] leading-none text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||
input.className = "min-w-0 flex-1 rounded border border-border/60 bg-background px-2 py-1 text-[11px] leading-none text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring";
|
||||
element.appendChild(input);
|
||||
|
||||
const matchCount = document.createElement("span");
|
||||
matchCount.dataset.sqlHoverSearchCount = "true";
|
||||
matchCount.className = "shrink-0 text-[11px] tabular-nums text-muted-foreground";
|
||||
matchCount.setAttribute("role", "status");
|
||||
matchCount.setAttribute("aria-live", "polite");
|
||||
matchCount.setAttribute("aria-atomic", "true");
|
||||
matchCount.hidden = true;
|
||||
element.appendChild(matchCount);
|
||||
|
||||
const status = document.createElement("div");
|
||||
status.dataset.sqlHoverSearchEmpty = "true";
|
||||
status.className = "mt-1.5 text-[11px] text-muted-foreground";
|
||||
status.textContent = noResultLabel;
|
||||
status.hidden = true;
|
||||
|
||||
let matchGroups: HTMLElement[][] = [];
|
||||
let activeMatchIndex = -1;
|
||||
let isComposing = false;
|
||||
|
||||
const activateMatch = (index: number) => {
|
||||
for (const mark of matchGroups[activeMatchIndex] ?? []) mark.removeAttribute(ACTIVE_ATTRIBUTE);
|
||||
activeMatchIndex = index;
|
||||
for (const mark of matchGroups[index]) mark.setAttribute(ACTIVE_ATTRIBUTE, "true");
|
||||
matchCount.textContent = `${index + 1} / ${matchGroups.length}`;
|
||||
matchGroups[index][0]?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
|
||||
const runSearch = () => {
|
||||
// Restore the pristine highlighted DDL, then re-apply match highlights.
|
||||
target.innerHTML = originalHtml;
|
||||
matchGroups = [];
|
||||
activeMatchIndex = -1;
|
||||
const query = input.value;
|
||||
// Search the rendered text, not the raw DDL string: the highlighter emits
|
||||
// `<br>` for line breaks, so `textContent` differs from the raw DDL by the
|
||||
@@ -168,23 +198,27 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
|
||||
// `textContent` domain. Using any other corpus shifts every match after
|
||||
// the first line.
|
||||
const matches = findHoverSearchMatches(target.textContent ?? "", query);
|
||||
if (!query.trim()) {
|
||||
status.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
status.hidden = false;
|
||||
return;
|
||||
}
|
||||
status.hidden = true;
|
||||
const hasQuery = query.trim().length > 0;
|
||||
matchCount.hidden = !hasQuery;
|
||||
matchCount.textContent = hasQuery ? "0 / 0" : "";
|
||||
status.hidden = !hasQuery || matches.length > 0;
|
||||
if (matches.length === 0) return;
|
||||
|
||||
const marks = applyHoverSearchHighlights(target, matches);
|
||||
marks[0]?.scrollIntoView({ block: "nearest" });
|
||||
matchGroups = Array.from({ length: matches.length }, () => []);
|
||||
for (const mark of marks) matchGroups[Number(mark.getAttribute(MATCH_INDEX_ATTRIBUTE))].push(mark);
|
||||
activateMatch(0);
|
||||
};
|
||||
|
||||
const stopKeyboard = (event: KeyboardEvent) => {
|
||||
// Keep editor keymap shortcuts and IME Enter/Escape from leaking out of the
|
||||
// input; Escape clears the search but keeps the tooltip open.
|
||||
event.stopPropagation();
|
||||
if (event.type !== "keydown" || isComposing || event.isComposing || event.keyCode === 229) return;
|
||||
if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
if (matchGroups.length > 0) activateMatch((activeMatchIndex + (event.shiftKey ? -1 : 1) + matchGroups.length) % matchGroups.length);
|
||||
}
|
||||
if (event.key === "Escape" && input.value) {
|
||||
event.preventDefault();
|
||||
input.value = "";
|
||||
@@ -194,10 +228,18 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
|
||||
const stopPointer = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
const startComposition = () => {
|
||||
isComposing = true;
|
||||
};
|
||||
const endComposition = () => {
|
||||
isComposing = false;
|
||||
};
|
||||
|
||||
input.addEventListener("input", runSearch);
|
||||
input.addEventListener("keydown", stopKeyboard);
|
||||
input.addEventListener("keyup", stopKeyboard);
|
||||
input.addEventListener("compositionstart", startComposition);
|
||||
input.addEventListener("compositionend", endComposition);
|
||||
input.addEventListener("pointerdown", stopPointer);
|
||||
input.addEventListener("mousedown", stopPointer);
|
||||
|
||||
@@ -211,6 +253,8 @@ export function createHoverSearch(options: HoverSearchOptions): HoverSearchContr
|
||||
input.removeEventListener("input", runSearch);
|
||||
input.removeEventListener("keydown", stopKeyboard);
|
||||
input.removeEventListener("keyup", stopKeyboard);
|
||||
input.removeEventListener("compositionstart", startComposition);
|
||||
input.removeEventListener("compositionend", endComposition);
|
||||
input.removeEventListener("pointerdown", stopPointer);
|
||||
input.removeEventListener("mousedown", stopPointer);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user