From ab67be97730cb688b2a1d3d142e5b71bcda10fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timo=20Westk=C3=A4mper?= Date: Wed, 18 Dec 2024 14:53:12 +0200 Subject: [PATCH] Prettify --- package.json | 3 +- spec/custom.spec.ts | 88 +++-- spec/liquid.spec.ts | 96 ++--- spec/mustache.spec.ts | 94 ++--- spec/react.spec.ts | 76 ++-- spec/suites/autocomplete.ts | 697 +++++++++++++++++----------------- src/api/client.ts | 6 +- src/autocomplete.ts | 451 +++++++++++----------- src/config.ts | 173 +++++---- src/defaults/Autocomplete.tsx | 280 +++++++------- src/entries/liquid.ts | 6 +- src/entries/mustache.ts | 6 +- src/liquid.ts | 74 ++-- src/mustache.ts | 115 +++--- src/search.ts | 145 ++++--- src/shims.d.ts | 16 +- src/utils/dom.ts | 105 ++--- src/utils/dropdown.ts | 440 +++++++++++---------- src/utils/ga.ts | 176 +++++---- src/utils/history.ts | 90 +++-- src/utils/input.ts | 162 ++++---- src/utils/limiter.ts | 144 +++---- src/utils/promise.ts | 42 +- src/utils/state.ts | 175 ++++----- 24 files changed, 1816 insertions(+), 1844 deletions(-) diff --git a/package.json b/package.json index 2154768..a589d7d 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,8 @@ "prettier": { "trailingComma": "es5", "semi": false, - "arrowParens": "avoid" + "arrowParens": "avoid", + "tabWidth": 2 }, "release": { "branches": [ diff --git a/spec/custom.spec.ts b/spec/custom.spec.ts index ccb124b..d1c3514 100644 --- a/spec/custom.spec.ts +++ b/spec/custom.spec.ts @@ -5,7 +5,7 @@ import "@testing-library/jest-dom" import { autocomplete } from "../src/autocomplete" beforeAll(() => { - document.body.innerHTML = ` + document.body.innerHTML = `
@@ -15,56 +15,54 @@ beforeAll(() => { }) afterAll(() => { - document.body.innerHTML = "" + document.body.innerHTML = "" }) describe("autocomplete", () => { - it("supports custom fetch function", async () => { - const user = userEvent.setup() + it("supports custom fetch function", async () => { + const user = userEvent.setup() - autocomplete({ - inputSelector: "#search", - dropdownSelector: "#search-results", - fetch(input) { - return Promise.resolve({ - items: [input, "blue"], - }) - }, - render: (container, state) => { - container.innerHTML = - state?.items?.length > 0 - ? state.items - .map(item => `
keyword ${item}
`) - .join("") - : "" - }, - submit: query => { - // Handle search submit - console.log("Submitting search with query: ", query) - }, + autocomplete({ + inputSelector: "#search", + dropdownSelector: "#search-results", + fetch(input) { + return Promise.resolve({ + items: [input, "blue"], }) + }, + render: (container, state) => { + container.innerHTML = + state?.items?.length > 0 + ? state.items.map(item => `
keyword ${item}
`).join("") + : "" + }, + submit: query => { + // Handle search submit + console.log("Submitting search with query: ", query) + }, + }) - await waitFor( - () => { - expect(screen.getByTestId("dropdown")).not.toBeVisible() - }, - { - timeout: 1000, - } - ) + await waitFor( + () => { + expect(screen.getByTestId("dropdown")).not.toBeVisible() + }, + { + timeout: 1000, + } + ) - await user.type(screen.getByTestId("input"), "red") + await user.type(screen.getByTestId("input"), "red") - await waitFor( - () => { - expect(screen.getByTestId("dropdown")).toBeVisible() - }, - { - timeout: 4000, - } - ) + await waitFor( + () => { + expect(screen.getByTestId("dropdown")).toBeVisible() + }, + { + timeout: 4000, + } + ) - expect(screen.getByText("keyword red")).toBeVisible() - expect(screen.getByText("keyword blue")).toBeVisible() - }) -}) \ No newline at end of file + expect(screen.getByText("keyword red")).toBeVisible() + expect(screen.getByText("keyword blue")).toBeVisible() + }) +}) diff --git a/spec/liquid.spec.ts b/spec/liquid.spec.ts index dc15a09..fb1b06d 100644 --- a/spec/liquid.spec.ts +++ b/spec/liquid.spec.ts @@ -1,70 +1,70 @@ import "@testing-library/jest-dom" import { - fromLiquidTemplate, - fromRemoteLiquidTemplate, - defaultLiquidTemplate as liquidTemplate, + fromLiquidTemplate, + fromRemoteLiquidTemplate, + defaultLiquidTemplate as liquidTemplate, } from "../src/liquid" import { - handleAutocomplete, - hooks, - autocompleteSuite, + handleAutocomplete, + hooks, + autocompleteSuite, } from "./suites/autocomplete" import { waitFor } from "@testing-library/dom" function libraryScript() { - const liquidScript = document.createElement("script") - liquidScript.src = - "https://cdn.jsdelivr.net/npm/liquidjs@10.9.3/dist/liquid.browser.min.js" - document.body.appendChild(liquidScript) + const liquidScript = document.createElement("script") + liquidScript.src = + "https://cdn.jsdelivr.net/npm/liquidjs@10.9.3/dist/liquid.browser.min.js" + document.body.appendChild(liquidScript) } describe("fromLiquidTemplate", () => { - autocompleteSuite({ - render: () => fromLiquidTemplate(liquidTemplate), - libraryScript, - }) + autocompleteSuite({ + render: () => fromLiquidTemplate(liquidTemplate), + libraryScript, + }) }) describe("fromRemoteLiquidTemplate", () => { - hooks(libraryScript) + hooks(libraryScript) - it("fetches remote templates url", async () => { - const openSpy = jest.spyOn(XMLHttpRequest.prototype, "open") - const sendSpy = jest.spyOn(XMLHttpRequest.prototype, "send") + it("fetches remote templates url", async () => { + const openSpy = jest.spyOn(XMLHttpRequest.prototype, "open") + const sendSpy = jest.spyOn(XMLHttpRequest.prototype, "send") - const mockUrl = "template.liquid" - const render = fromRemoteLiquidTemplate(mockUrl) + const mockUrl = "template.liquid" + const render = fromRemoteLiquidTemplate(mockUrl) - const mockXhr = { - open: jest.fn(), - send: jest.fn(), - status: 200, - responseText: liquidTemplate, - onload: jest.fn(), - onerror: jest.fn(), - } + const mockXhr = { + open: jest.fn(), + send: jest.fn(), + status: 200, + responseText: liquidTemplate, + onload: jest.fn(), + onerror: jest.fn(), + } - openSpy.mockImplementation((method, url) => { - if (url === mockUrl) { - return mockXhr.open(method, url) - } - return openSpy.mock.calls[0] - }) + openSpy.mockImplementation((method, url) => { + if (url === mockUrl) { + return mockXhr.open(method, url) + } + return openSpy.mock.calls[0] + }) - sendSpy.mockImplementation(() => { - return sendSpy.mock.calls[0] - }) + sendSpy.mockImplementation(() => { + return sendSpy.mock.calls[0] + }) - await waitFor(() => handleAutocomplete(render)) + await waitFor(() => handleAutocomplete(render)) - await waitFor( - () => { - expect(openSpy).toHaveBeenCalledWith("GET", mockUrl) - expect(sendSpy).toHaveBeenCalled() - }, - { - timeout: 1000, - } - ) - }) + await waitFor( + () => { + expect(openSpy).toHaveBeenCalledWith("GET", mockUrl) + expect(sendSpy).toHaveBeenCalled() + }, + { + timeout: 1000, + } + ) + }) }) diff --git a/spec/mustache.spec.ts b/spec/mustache.spec.ts index 79fa986..032647d 100644 --- a/spec/mustache.spec.ts +++ b/spec/mustache.spec.ts @@ -1,69 +1,69 @@ import "@testing-library/jest-dom" import { - fromMustacheTemplate, - fromRemoteMustacheTemplate, - defaultMustacheTemplate as mustacheTemplate, + fromMustacheTemplate, + fromRemoteMustacheTemplate, + defaultMustacheTemplate as mustacheTemplate, } from "../src/mustache" import { - autocompleteSuite, - handleAutocomplete, - hooks, + autocompleteSuite, + handleAutocomplete, + hooks, } from "./suites/autocomplete" import { waitFor } from "@testing-library/dom" function libraryScript() { - const mustacheScript = document.createElement("script") - mustacheScript.src = "https://unpkg.com/mustache@4.2.0/mustache.min.js" - document.body.appendChild(mustacheScript) + const mustacheScript = document.createElement("script") + mustacheScript.src = "https://unpkg.com/mustache@4.2.0/mustache.min.js" + document.body.appendChild(mustacheScript) } describe("fromMustacheTemplate", () => { - autocompleteSuite({ - render: () => fromMustacheTemplate(mustacheTemplate), - libraryScript, - }) + autocompleteSuite({ + render: () => fromMustacheTemplate(mustacheTemplate), + libraryScript, + }) }) describe("fromRemoteMustacheTemplate", () => { - hooks(libraryScript) + hooks(libraryScript) - it("fetches remote templates url", async () => { - const openSpy = jest.spyOn(XMLHttpRequest.prototype, "open") - const sendSpy = jest.spyOn(XMLHttpRequest.prototype, "send") + it("fetches remote templates url", async () => { + const openSpy = jest.spyOn(XMLHttpRequest.prototype, "open") + const sendSpy = jest.spyOn(XMLHttpRequest.prototype, "send") - const mockUrl = "template.mustache" - const render = fromRemoteMustacheTemplate(mockUrl) + const mockUrl = "template.mustache" + const render = fromRemoteMustacheTemplate(mockUrl) - const mockXhr = { - open: jest.fn(), - send: jest.fn(), - status: 200, - responseText: mustacheTemplate, - onload: jest.fn(), - onerror: jest.fn(), - } + const mockXhr = { + open: jest.fn(), + send: jest.fn(), + status: 200, + responseText: mustacheTemplate, + onload: jest.fn(), + onerror: jest.fn(), + } - openSpy.mockImplementation((method, url) => { - if (url === mockUrl) { - return mockXhr.open(method, url) - } - return openSpy.mock.calls[0] - }) + openSpy.mockImplementation((method, url) => { + if (url === mockUrl) { + return mockXhr.open(method, url) + } + return openSpy.mock.calls[0] + }) - sendSpy.mockImplementation(() => { - return sendSpy.mock.calls[0] - }) + sendSpy.mockImplementation(() => { + return sendSpy.mock.calls[0] + }) - await waitFor(() => handleAutocomplete(render)) + await waitFor(() => handleAutocomplete(render)) - await waitFor( - () => { - expect(openSpy).toHaveBeenCalledWith("GET", mockUrl) - expect(sendSpy).toHaveBeenCalled() - }, - { - timeout: 1000, - } - ) - }) + await waitFor( + () => { + expect(openSpy).toHaveBeenCalledWith("GET", mockUrl) + expect(sendSpy).toHaveBeenCalled() + }, + { + timeout: 1000, + } + ) + }) }) diff --git a/spec/react.spec.ts b/spec/react.spec.ts index 077ada6..f02ffea 100644 --- a/spec/react.spec.ts +++ b/spec/react.spec.ts @@ -6,54 +6,54 @@ import { DefaultState } from "../src/utils/state" import { autocompleteSuite } from "./suites/autocomplete" interface WindowWithReact extends Window { - React?: typeof React - ReactDOM?: typeof ReactDOM + React?: typeof React + ReactDOM?: typeof ReactDOM } let reactRoot: ReactDOM.Root | undefined const w = window as unknown as WindowWithReact function libraryScript() { - const reactScript = document.createElement("script") - reactScript.src = "https://unpkg.com/react@18/umd/react.production.min.js" - document.body.appendChild(reactScript) - - const reactDomScript = document.createElement("script") - reactDomScript.src = - "https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" - document.body.appendChild(reactDomScript) - - const babelScript = document.createElement("script") - babelScript.src = "https://unpkg.com/babel-standalone@6/babel.min.js" - document.body.appendChild(babelScript) + const reactScript = document.createElement("script") + reactScript.src = "https://unpkg.com/react@18/umd/react.production.min.js" + document.body.appendChild(reactScript) + + const reactDomScript = document.createElement("script") + reactDomScript.src = + "https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" + document.body.appendChild(reactDomScript) + + const babelScript = document.createElement("script") + babelScript.src = "https://unpkg.com/babel-standalone@6/babel.min.js" + document.body.appendChild(babelScript) } function render(container: HTMLElement, state: DefaultState) { - if (!reactRoot) { - reactRoot = w.ReactDOM?.createRoot(container) - } - reactRoot?.render( - w.React?.createElement(Autocomplete, { - history: state.history, - response: { - products: { - hits: state.response?.products?.hits ?? [], - }, - keywords: { - hits: state.response?.keywords?.hits ?? [], - }, - }, - }) - ) + if (!reactRoot) { + reactRoot = w.ReactDOM?.createRoot(container) + } + reactRoot?.render( + w.React?.createElement(Autocomplete, { + history: state.history, + response: { + products: { + hits: state.response?.products?.hits ?? [], + }, + keywords: { + hits: state.response?.keywords?.hits ?? [], + }, + }, + }) + ) } describe("from react component", () => { - afterEach(() => { - reactRoot = undefined - }) - - autocompleteSuite({ - render: () => render, - libraryScript, - }) + afterEach(() => { + reactRoot = undefined + }) + + autocompleteSuite({ + render: () => render, + libraryScript, + }) }) diff --git a/spec/suites/autocomplete.ts b/spec/suites/autocomplete.ts index b0b5abb..500c503 100644 --- a/spec/suites/autocomplete.ts +++ b/spec/suites/autocomplete.ts @@ -4,9 +4,9 @@ import searchResponse from "../responses/search.json" import "@testing-library/jest-dom" import { - AutocompleteConfig, - DefaultState, - autocomplete, + AutocompleteConfig, + DefaultState, + autocomplete, } from "../../src/entries/base" import { getDefaultConfig } from "../../src/config" import type { API, SearchResult } from "@nosto/nosto-js/client" @@ -17,37 +17,30 @@ type MockRecordSearchSubmit = jest.Mock> type MockRecordSearchClick = jest.Mock> export const handleAutocomplete = async ( - render: AutocompleteConfig["render"], - submit?: AutocompleteConfig["submit"] + render: AutocompleteConfig["render"], + submit?: AutocompleteConfig["submit"] ) => { - autocomplete({ - fetch: { - products: { - fields: [ - "name", - "url", - "imageUrl", - "price", - "listPrice", - "brand", - ], - size: 5, - }, - // @ts-expect-error missing fields - keywords: { - size: 5, - fields: ["keyword", "_highlight.keyword"], - highlight: { - preTag: ``, - postTag: "", - }, - }, + autocomplete({ + fetch: { + products: { + fields: ["name", "url", "imageUrl", "price", "listPrice", "brand"], + size: 5, + }, + // @ts-expect-error missing fields + keywords: { + size: 5, + fields: ["keyword", "_highlight.keyword"], + highlight: { + preTag: ``, + postTag: "", }, - inputSelector: "#search", - dropdownSelector: "#search-results", - render, - submit: submit ?? getDefaultConfig().submit, - }) + }, + }, + inputSelector: "#search", + dropdownSelector: "#search-results", + render, + submit: submit ?? getDefaultConfig().submit, + }) } const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) @@ -57,8 +50,8 @@ let recordSearchSubmitSpy: MockRecordSearchSubmit let recordSearchClickSpy: MockRecordSearchClick export function hooks(libraryScript: () => void) { - beforeEach(() => { - document.body.innerHTML = ` + beforeEach(() => { + document.body.innerHTML = ` @@ -66,411 +59,409 @@ export function hooks(libraryScript: () => void) { ` - libraryScript() - - searchSpy = jest.fn(async () => (searchResponse as unknown as SearchResult)) - recordSearchSubmitSpy = jest.fn() - recordSearchClickSpy = jest.fn() + libraryScript() - mockNostojs({ - search: searchSpy, - recordSearchSubmit: recordSearchSubmitSpy, - recordSearchClick: recordSearchClickSpy, - }) - }) + searchSpy = jest.fn(async () => searchResponse as unknown as SearchResult) + recordSearchSubmitSpy = jest.fn() + recordSearchClickSpy = jest.fn() - afterEach(() => { - jest.restoreAllMocks() - const dropdown = screen.getByTestId("dropdown") - const newElement = dropdown.cloneNode(true) - dropdown?.parentNode?.replaceChild(newElement, dropdown) - document.body.innerHTML = "" + mockNostojs({ + search: searchSpy, + recordSearchSubmit: recordSearchSubmitSpy, + recordSearchClick: recordSearchClickSpy, }) + }) + + afterEach(() => { + jest.restoreAllMocks() + const dropdown = screen.getByTestId("dropdown") + const newElement = dropdown.cloneNode(true) + dropdown?.parentNode?.replaceChild(newElement, dropdown) + document.body.innerHTML = "" + }) } export function autocompleteSuite({ - render, - libraryScript, + render, + libraryScript, }: { - render: () => AutocompleteConfig["render"] - libraryScript: () => void + render: () => AutocompleteConfig["render"] + libraryScript: () => void }) { - hooks(libraryScript) + hooks(libraryScript) - it("renders autocomplete", async () => { - const user = userEvent.setup() + it("renders autocomplete", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + await waitFor(() => handleAutocomplete(render())) - await waitFor( - () => { - expect(screen.getByTestId("dropdown")).not.toBeVisible() - }, - { - timeout: 1000, - } - ) + await waitFor( + () => { + expect(screen.getByTestId("dropdown")).not.toBeVisible() + }, + { + timeout: 1000, + } + ) - await user.type(screen.getByTestId("input"), "black") + await user.type(screen.getByTestId("input"), "black") - await waitFor( - () => { - expect(screen.getByTestId("dropdown")).toBeVisible() + await waitFor( + () => { + expect(screen.getByTestId("dropdown")).toBeVisible() - expect(screen.getByText("Keywords")).toBeVisible() - expect(screen.getAllByTestId("keyword")).toHaveLength(5) + expect(screen.getByText("Keywords")).toBeVisible() + expect(screen.getAllByTestId("keyword")).toHaveLength(5) - expect(screen.getByText("Products")).toBeVisible() - expect(screen.getAllByTestId("product")).toHaveLength(5) - }, - { - timeout: 4000, - } - ) - }) + expect(screen.getByText("Products")).toBeVisible() + expect(screen.getAllByTestId("product")).toHaveLength(5) + }, + { + timeout: 4000, + } + ) + }) - describe("history", () => { - it("should see results after typing", async () => { - const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + describe("history", () => { + it("should see results after typing", async () => { + const user = userEvent.setup() + await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await user.type(screen.getByTestId("input"), "black") - await waitFor( - () => { - expect(screen.getByTestId("dropdown")).toBeVisible() + await waitFor( + () => { + expect(screen.getByTestId("dropdown")).toBeVisible() - expect(screen.getByText("Keywords")).toBeVisible() - expect(screen.getAllByTestId("keyword")).toHaveLength(5) + expect(screen.getByText("Keywords")).toBeVisible() + expect(screen.getAllByTestId("keyword")).toHaveLength(5) - expect(screen.getByText("Products")).toBeVisible() - expect(screen.getAllByTestId("product")).toHaveLength(5) - }, - { - timeout: 4000, - } - ) - }) + expect(screen.getByText("Products")).toBeVisible() + expect(screen.getAllByTestId("product")).toHaveLength(5) + }, + { + timeout: 4000, + } + ) + }) - it("should see history on empty input", async () => { - const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + it("should see history on empty input", async () => { + const user = userEvent.setup() + await waitFor(() => handleAutocomplete(render())) - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "black") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) - await waitFor(() => { - const historyElement = screen.getByText("Recently searched") - return expect(historyElement).toBeVisible() - }) - }) + await waitFor(() => { + const historyElement = screen.getByText("Recently searched") + return expect(historyElement).toBeVisible() + }) + }) - it("should show history keyword", async () => { - const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + it("should show history keyword", async () => { + const user = userEvent.setup() + await waitFor(() => handleAutocomplete(render())) - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "black") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) - await waitFor(() => expect(screen.getByText("black")).toBeVisible()) - }) + await waitFor(() => expect(screen.getByText("black")).toBeVisible()) + }) - it("should navigate and select history keywords with keyboard", async () => { - const user = userEvent.setup() - const expectedQuery = "black" - let exactQuery = "" - await waitFor(() => - handleAutocomplete(render(), query => { - exactQuery = query - }) - ) - - await user.type(screen.getByTestId("input"), expectedQuery) - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) - - await user.type(screen.getByTestId("input"), "white") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) - - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowup}") - await user.keyboard("{enter}") - - expect(exactQuery).toBe(expectedQuery) + it("should navigate and select history keywords with keyboard", async () => { + const user = userEvent.setup() + const expectedQuery = "black" + let exactQuery = "" + await waitFor(() => + handleAutocomplete(render(), query => { + exactQuery = query }) + ) - it("should show two history keywords", async () => { - const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), expectedQuery) + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "red") - await user.click(screen.getByTestId("search-button")) + await user.type(screen.getByTestId("input"), "white") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "black") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowup}") + await user.keyboard("{enter}") - await waitFor(() => expect(screen.getByText("black")).toBeVisible()) - await waitFor(() => expect(screen.getByText("red")).toBeVisible()) - }) + expect(exactQuery).toBe(expectedQuery) + }) - it("should clear history keyword", async () => { - const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) - - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "red") - await user.keyboard("{enter}") - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "black") - await user.keyboard("{enter}") - await user.clear(screen.getByTestId("input")) - - await waitFor(async () => { - const removeHistoryElement = screen.queryByTestId( - "remove-history-black" - ) - if (removeHistoryElement) { - userEvent.click(removeHistoryElement) - return waitFor(() => - expect(screen.queryByText("black")).toBeNull() - ) - } - }) - }) + it("should show two history keywords", async () => { + const user = userEvent.setup() + await waitFor(() => handleAutocomplete(render())) - it("should clear history", async () => { - const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) - - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "red") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "black") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) - await user.click(screen.getByText("Clear history")) - - await waitFor(() => expect(screen.queryByText("black")).toBeNull()) - await waitFor(() => expect(screen.queryByText("red")).toBeNull()) - }) + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "red") + await user.click(screen.getByTestId("search-button")) + + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) + + await waitFor(() => expect(screen.getByText("black")).toBeVisible()) + await waitFor(() => expect(screen.getByText("red")).toBeVisible()) + }) + + it("should clear history keyword", async () => { + const user = userEvent.setup() + await waitFor(() => handleAutocomplete(render())) + + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "red") + await user.keyboard("{enter}") + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "black") + await user.keyboard("{enter}") + await user.clear(screen.getByTestId("input")) + + await waitFor(async () => { + const removeHistoryElement = screen.queryByTestId( + "remove-history-black" + ) + if (removeHistoryElement) { + userEvent.click(removeHistoryElement) + return waitFor(() => expect(screen.queryByText("black")).toBeNull()) + } + }) + }) + + it("should clear history", async () => { + const user = userEvent.setup() + await waitFor(() => handleAutocomplete(render())) + + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "red") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) + await user.click(screen.getByText("Clear history")) + + await waitFor(() => expect(screen.queryByText("black")).toBeNull()) + await waitFor(() => expect(screen.queryByText("red")).toBeNull()) + }) - it("should highlight history keyword with keyboard navigation", async () => { - const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + it("should highlight history keyword with keyboard navigation", async () => { + const user = userEvent.setup() + await waitFor(() => handleAutocomplete(render())) - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "red") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) - await user.type(screen.getByTestId("input"), "black") - await user.click(screen.getByTestId("search-button")) - await user.clear(screen.getByTestId("input")) + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "red") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) + await user.clear(screen.getByTestId("input")) - await waitFor(() => expect(screen.getByText("black")).toBeVisible()) - await waitFor(() => expect(screen.getByText("red")).toBeVisible()) + await waitFor(() => expect(screen.getByText("black")).toBeVisible()) + await waitFor(() => expect(screen.getByText("red")).toBeVisible()) - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowup}") + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowup}") - await waitFor(() => - expect(screen.getByText("black")).toHaveClass("selected") - ) - }) + await waitFor(() => + expect(screen.getByText("black")).toHaveClass("selected") + ) }) + }) - describe("analytics", () => { - it("should record search submit", async () => { - const user = userEvent.setup() + describe("analytics", () => { + it("should record search submit", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") - await user.click(screen.getByTestId("search-button")) + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) - await waitFor(() => - expect(recordSearchSubmitSpy).toHaveBeenCalledWith("black") - ) - }) + await waitFor(() => + expect(recordSearchSubmitSpy).toHaveBeenCalledWith("black") + ) + }) - it("should call search with isKeyword=false", async () => { - const user = userEvent.setup() + it("should call search with isKeyword=false", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") - await user.click(screen.getByTestId("search-button")) + await user.type(screen.getByTestId("input"), "black") + await user.click(screen.getByTestId("search-button")) - await waitFor(() => - expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { - track: "serp", - redirect: true, - isKeyword: false, - }) - ) + await waitFor(() => + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: true, + isKeyword: false, }) + ) + }) - it("should record search submit with keyboard", async () => { - const user = userEvent.setup() + it("should record search submit with keyboard", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), "black") - await waitFor(async () => { - await user.keyboard("{enter}") - expect(recordSearchSubmitSpy).toHaveBeenCalledWith("black") - }) - }) + await waitFor(async () => { + await user.keyboard("{enter}") + expect(recordSearchSubmitSpy).toHaveBeenCalledWith("black") + }) + }) - it("should call search with keyboard with isKeyword=false", async () => { - const user = userEvent.setup() + it("should call search with keyboard with isKeyword=false", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), "black") - await waitFor(async () => { - await user.keyboard("{enter}") - expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { - track: "serp", - redirect: true, - isKeyword: false, - }) - }) + await waitFor(async () => { + await user.keyboard("{enter}") + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: true, + isKeyword: false, }) + }) + }) - it("should record search click on keyword click", async () => { - const user = userEvent.setup() + it("should record search click on keyword click", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), "black") - await waitFor(async () => { - await user.click(screen.getAllByTestId("keyword")?.[0]) - expect(recordSearchClickSpy).toHaveBeenCalledWith( - "autocomplete", - searchResponse.keywords.hits[0] - ) - }) - }) + await waitFor(async () => { + await user.click(screen.getAllByTestId("keyword")?.[0]) + expect(recordSearchClickSpy).toHaveBeenCalledWith( + "autocomplete", + searchResponse.keywords.hits[0] + ) + }) + }) - it("should call search on keyword click with isKeyword=true", async () => { - const user = userEvent.setup() + it("should call search on keyword click with isKeyword=true", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), "black") - await waitFor(async () => { - await user.click(screen.getAllByTestId("keyword")?.[0]) + await waitFor(async () => { + await user.click(screen.getAllByTestId("keyword")?.[0]) - expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { - track: "serp", - redirect: false, - isKeyword: true, - }) - }) + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: false, + isKeyword: true, }) + }) + }) - it("should record search click on product click", async () => { - const user = userEvent.setup() + it("should record search click on product click", async () => { + const user = userEvent.setup() - const assignMock = jest.fn(() => ({})) + const assignMock = jest.fn(() => ({})) - const oldLocation = window.location - // @ts-expect-error: mock location - delete window.location - window.location = { ...oldLocation, assign: assignMock } + const oldLocation = window.location + // @ts-expect-error: mock location + delete window.location + window.location = { ...oldLocation, assign: assignMock } - await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await waitFor(() => handleAutocomplete(render())) + await user.type(screen.getByTestId("input"), "black") - await waitFor(async () => { - await user.click(screen.getAllByTestId("product")?.[0]) - expect(recordSearchClickSpy).toHaveBeenCalledWith( - "autocomplete", - searchResponse.products.hits[0] - ) - }) + await waitFor(async () => { + await user.click(screen.getAllByTestId("product")?.[0]) + expect(recordSearchClickSpy).toHaveBeenCalledWith( + "autocomplete", + searchResponse.products.hits[0] + ) + }) - assignMock.mockClear() - }) + assignMock.mockClear() + }) - it("should record search click on keyword submitted with keyboard", async () => { - const user = userEvent.setup() + it("should record search click on keyword submitted with keyboard", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await user.type(screen.getByTestId("input"), "black") - await waitFor(async () => { - await user.keyboard("{arrowdown}") - await user.keyboard("{enter}") - expect(recordSearchClickSpy).toHaveBeenCalledWith( - "autocomplete", - searchResponse.keywords.hits[0] - ) - }) - }) + await waitFor(async () => { + await user.keyboard("{arrowdown}") + await user.keyboard("{enter}") + expect(recordSearchClickSpy).toHaveBeenCalledWith( + "autocomplete", + searchResponse.keywords.hits[0] + ) + }) + }) - it("should record search click on product submitted with keyboard", async () => { - const user = userEvent.setup() + it("should record search click on product submitted with keyboard", async () => { + const user = userEvent.setup() - const assignMock = jest.fn(() => ({})) + const assignMock = jest.fn(() => ({})) - const oldLocation = window.location - // @ts-expect-error: mock location - delete window.location - window.location = { ...oldLocation, assign: assignMock } + const oldLocation = window.location + // @ts-expect-error: mock location + delete window.location + window.location = { ...oldLocation, assign: assignMock } - await waitFor(() => handleAutocomplete(render())) + await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") - await wait(500) - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{enter}") + await user.type(screen.getByTestId("input"), "black") + await wait(500) + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{enter}") - await waitFor(() => { - expect(recordSearchClickSpy).toHaveBeenCalledWith( - "autocomplete", - searchResponse.products.hits[0] - ) - }) + await waitFor(() => { + expect(recordSearchClickSpy).toHaveBeenCalledWith( + "autocomplete", + searchResponse.products.hits[0] + ) + }) - assignMock.mockClear() - }) + assignMock.mockClear() + }) - it("should call search when keyword is submitted with keyboard, with isKeyword=true", async () => { - const user = userEvent.setup() + it("should call search when keyword is submitted with keyboard, with isKeyword=true", async () => { + const user = userEvent.setup() - await waitFor(() => handleAutocomplete(render())) + await waitFor(() => handleAutocomplete(render())) - await user.type(screen.getByTestId("input"), "black") + await user.type(screen.getByTestId("input"), "black") - await waitFor(async () => { - await user.keyboard("{arrowdown}") - await user.keyboard("{arrowdown}") - await user.keyboard("{enter}") + await waitFor(async () => { + await user.keyboard("{arrowdown}") + await user.keyboard("{arrowdown}") + await user.keyboard("{enter}") - expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { - track: "serp", - redirect: false, - isKeyword: true, - }) - }) + expect(searchSpy).toHaveBeenCalledWith(expect.anything(), { + track: "serp", + redirect: false, + isKeyword: true, }) + }) }) + }) } diff --git a/src/api/client.ts b/src/api/client.ts index 2a568f6..f73d026 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -12,9 +12,9 @@ type LogLevel = "error" | "warn" | "info" | "debug" * @category Core */ export function getNostoClient(): Promise { - return new Promise(nostojs) + return new Promise(nostojs) } export function log(level: LogLevel, ...args: unknown[]) { - nostojs(api => api.internal.logger[level](...args)) -} \ No newline at end of file + nostojs(api => api.internal.logger[level](...args)) +} diff --git a/src/autocomplete.ts b/src/autocomplete.ts index 916b346..f6e6696 100644 --- a/src/autocomplete.ts +++ b/src/autocomplete.ts @@ -11,21 +11,24 @@ import { createHistory } from "./utils/history" import type { SearchOptions } from "@nosto/nosto-js/client" export type AutocompleteInstance = { - /** - * Open the dropdown. - */ - open(): void - /** - * Close the dropdown. - */ - close(): void - /** - * Destroy the autocomplete instance. - */ - destroy(): void + /** + * Open the dropdown. + */ + open(): void + /** + * Close the dropdown. + */ + close(): void + /** + * Destroy the autocomplete instance. + */ + destroy(): void } -export type SearchAutocompleteOptions = Pick +export type SearchAutocompleteOptions = Pick< + SearchOptions, + "isKeyword" | "redirect" +> /** * @param config Autocomplete configuration. @@ -112,260 +115,260 @@ export type SearchAutocompleteOptions = Pick( - config: AutocompleteConfig + config: AutocompleteConfig ): AutocompleteInstance { - const fullConfig = { - ...getDefaultConfig(), - ...config, - } satisfies AutocompleteConfig + const fullConfig = { + ...getDefaultConfig(), + ...config, + } satisfies AutocompleteConfig - const history = fullConfig.historyEnabled - ? createHistory(fullConfig.historySize) - : undefined + const history = fullConfig.historyEnabled + ? createHistory(fullConfig.historySize) + : undefined - const limiter = createLimiter(300, 1) + const limiter = createLimiter(300, 1) - const dropdowns = findAll(config.inputSelector, HTMLInputElement).map( - inputElement => { - const actions = getStateActions({ - config: fullConfig, - history, - input: inputElement, - }) + const dropdowns = findAll(config.inputSelector, HTMLInputElement).map( + inputElement => { + const actions = getStateActions({ + config: fullConfig, + history, + input: inputElement, + }) - const dropdown = createInputDropdown({ - input: inputElement, - config: fullConfig, - actions, - }) + const dropdown = createInputDropdown({ + input: inputElement, + config: fullConfig, + actions, + }) - if (!dropdown) { - return - } + if (!dropdown) { + return + } - const input = bindInput(inputElement, { - onInput: async value => { - try { - await limiter.limited(async () => { - const state = await actions.updateState(value) - dropdown.update(state) - }) - } catch (err) { - if ( - !( - err instanceof LimiterError || - err instanceof CancellableError - ) - ) { - throw err - } - } - }, - onClick() { - dropdown.resetHighlight() - }, - onFocus() { - dropdown.show() - }, - onBlur() { - dropdown.hide() - }, - onSubmit() { - submitWithContext({ - config: fullConfig, - actions, - })(inputElement.value) - dropdown.hide() - }, - onKeyDown(_, key) { - if (key === "Escape") { - dropdown.hide() - } else if (key === "ArrowDown") { - if (dropdown.isOpen()) { - dropdown.goDown() - } else { - dropdown.show() - } - } else if (key === "ArrowUp") { - if (dropdown.isOpen()) { - dropdown.goUp() - } - } else if (key === "Enter") { - if (dropdown.isOpen() && dropdown.hasHighlight()) { - const data = dropdown.getHighlight()?.dataset?.nsHit - if (data) { - trackClick({ - config: fullConfig, - data, - query: inputElement.value, - }) - } - dropdown.handleSubmit() - } else { - submitWithContext({ - actions, - config: fullConfig, - })(inputElement.value) - } - } - }, + const input = bindInput(inputElement, { + onInput: async value => { + try { + await limiter.limited(async () => { + const state = await actions.updateState(value) + dropdown.update(state) }) - - const clickOutside = bindClickOutside( - [dropdown.container, inputElement], - () => { - dropdown.hide() - } - ) - - return { - open() { - dropdown.show() - }, - close() { - dropdown.hide() - }, - destroy() { - input.destroy() - clickOutside.destroy() - dropdown.destroy() - }, + } catch (err) { + if ( + !(err instanceof LimiterError || err instanceof CancellableError) + ) { + throw err } + } + }, + onClick() { + dropdown.resetHighlight() + }, + onFocus() { + dropdown.show() + }, + onBlur() { + dropdown.hide() + }, + onSubmit() { + submitWithContext({ + config: fullConfig, + actions, + })(inputElement.value) + dropdown.hide() + }, + onKeyDown(_, key) { + if (key === "Escape") { + dropdown.hide() + } else if (key === "ArrowDown") { + if (dropdown.isOpen()) { + dropdown.goDown() + } else { + dropdown.show() + } + } else if (key === "ArrowUp") { + if (dropdown.isOpen()) { + dropdown.goUp() + } + } else if (key === "Enter") { + if (dropdown.isOpen() && dropdown.hasHighlight()) { + const data = dropdown.getHighlight()?.dataset?.nsHit + if (data) { + trackClick({ + config: fullConfig, + data, + query: inputElement.value, + }) + } + dropdown.handleSubmit() + } else { + submitWithContext({ + actions, + config: fullConfig, + })(inputElement.value) + } + } + }, + }) + + const clickOutside = bindClickOutside( + [dropdown.container, inputElement], + () => { + dropdown.hide() } - ) + ) - return { - destroy() { - dropdowns.forEach(dropdown => dropdown?.destroy()) - }, + return { open() { - dropdowns.forEach(dropdown => dropdown?.open()) + dropdown.show() }, close() { - dropdowns.forEach(dropdown => dropdown?.close()) + dropdown.hide() + }, + destroy() { + input.destroy() + clickOutside.destroy() + dropdown.destroy() }, + } } + ) + + return { + destroy() { + dropdowns.forEach(dropdown => dropdown?.destroy()) + }, + open() { + dropdowns.forEach(dropdown => dropdown?.open()) + }, + close() { + dropdowns.forEach(dropdown => dropdown?.close()) + }, + } } function createInputDropdown({ - input, - config, - actions, + input, + config, + actions, }: { - input: HTMLInputElement - config: AutocompleteConfig - actions: StateActions + input: HTMLInputElement + config: AutocompleteConfig + actions: StateActions }): Dropdown | undefined { - const dropdownElements = - typeof config.dropdownSelector === "function" - ? findAll(config.dropdownSelector(input), HTMLElement) - : findAll(config.dropdownSelector, HTMLElement) + const dropdownElements = + typeof config.dropdownSelector === "function" + ? findAll(config.dropdownSelector(input), HTMLElement) + : findAll(config.dropdownSelector, HTMLElement) - if (dropdownElements.length === 0) { - log("error", `No dropdown element found for input ${input}`) - return - } else if (dropdownElements.length > 1) { - log("error", `Multiple dropdown elements found for input ${input}, using the first element`) - } + if (dropdownElements.length === 0) { + log("error", `No dropdown element found for input ${input}`) + return + } else if (dropdownElements.length > 1) { + log( + "error", + `Multiple dropdown elements found for input ${input}, using the first element` + ) + } - const dropdownElement = dropdownElements[0] + const dropdownElement = dropdownElements[0] - return createDropdown( - dropdownElement, - actions.updateState(input.value), - config.render, - submitWithContext({ - actions, - config, - }), - value => (input.value = value), - { - removeHistory: async function ({ data, update }) { - if (data === "all") { - const state = await actions.clearHistory(); - return update(state); - } else if (data) { - const state = await actions.removeHistoryItem(data); - return update(state); - } - }, - hit: function ({ data }) { - if (data) { - trackClick({ config, data, query: input.value }) - } - }, + return createDropdown( + dropdownElement, + actions.updateState(input.value), + config.render, + submitWithContext({ + actions, + config, + }), + value => (input.value = value), + { + removeHistory: async function ({ data, update }) { + if (data === "all") { + const state = await actions.clearHistory() + return update(state) + } else if (data) { + const state = await actions.removeHistoryItem(data) + return update(state) } - ) + }, + hit: function ({ data }) { + if (data) { + trackClick({ config, data, query: input.value }) + } + }, + } + ) } async function trackClick({ - config, - data, - query, + config, + data, + query, }: { - config: AutocompleteConfig - data: string - query: string + config: AutocompleteConfig + data: string + query: string }) { - if (!config.googleAnalytics && !config.nostoAnalytics) { - return - } + if (!config.googleAnalytics && !config.nostoAnalytics) { + return + } - const parsedHit = parseHit(data) + const parsedHit = parseHit(data) - if (config.nostoAnalytics) { - if (parsedHit) { - const api = await getNostoClient() - // @ts-expect-error type mismatch - api?.recordSearchClick?.("autocomplete", parsedHit) - } + if (config.nostoAnalytics) { + if (parsedHit) { + const api = await getNostoClient() + // @ts-expect-error type mismatch + api?.recordSearchClick?.("autocomplete", parsedHit) } + } - if (isGaEnabled(config)) { - if (parsedHit._redirect) { - trackGaPageView({ - delay: true, - location: getGaTrackUrl(parsedHit.keyword, config), - }) - } + if (isGaEnabled(config)) { + if (parsedHit._redirect) { + trackGaPageView({ + delay: true, + location: getGaTrackUrl(parsedHit.keyword, config), + }) + } - if (parsedHit.url) { - trackGaPageView({ - delay: true, - location: getGaTrackUrl(query, config), - }) - } + if (parsedHit.url) { + trackGaPageView({ + delay: true, + location: getGaTrackUrl(query, config), + }) } + } } function submitWithContext(context: { - config: AutocompleteConfig - actions: StateActions + config: AutocompleteConfig + actions: StateActions }) { - return async (value: string, options?: SearchAutocompleteOptions) => { - const { config, actions } = context - const { redirect = false } = options ?? {} + return async (value: string, options?: SearchAutocompleteOptions) => { + const { config, actions } = context + const { redirect = false } = options ?? {} - if (value.length > 0) { - if (config.historyEnabled) { - actions.addHistoryItem(value) - } + if (value.length > 0) { + if (config.historyEnabled) { + actions.addHistoryItem(value) + } - if (config.nostoAnalytics) { - const api = await getNostoClient() - api?.recordSearchSubmit?.(value) - } + if (config.nostoAnalytics) { + const api = await getNostoClient() + api?.recordSearchSubmit?.(value) + } - if (isGaEnabled(config)) { - trackGaPageView({ - delay: true, - location: getGaTrackUrl(value, config), - }) - } + if (isGaEnabled(config)) { + trackGaPageView({ + delay: true, + location: getGaTrackUrl(value, config), + }) + } - if (!redirect && typeof config?.submit === "function") { - config.submit(value, config, options) - } - } + if (!redirect && typeof config?.submit === "function") { + config.submit(value, config, options) + } } + } } diff --git a/src/config.ts b/src/config.ts index 42fd24a..936e032 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,21 +7,21 @@ import { search } from "./search" * @category Core */ export interface GoogleAnalyticsConfig { - /** - * Path of search page - * @default "/search" - */ - serpPath?: string - /** - * Search query url parameter name - * @default "query" - */ - queryParamName?: string - /** - * Enable Google Analytics - * @default true - */ - enabled?: boolean + /** + * Path of search page + * @default "/search" + */ + serpPath?: string + /** + * Search query url parameter name + * @default "query" + */ + queryParamName?: string + /** + * Enable Google Analytics + * @default true + */ + enabled?: boolean } type Selector = string | Element @@ -31,82 +31,81 @@ type Selector = string | Element * @category Core */ export interface AutocompleteConfig { - /** - * The input element to attach the autocomplete to - */ - inputSelector: Selector - /** - * The dropdown element to attach the autocomplete to - */ - dropdownSelector: Selector | ((input: HTMLInputElement) => Selector) - /** - * The function to use to render the dropdown - */ - render: (container: HTMLElement, state: State) => void | PromiseLike - /** - * Minimum length of the query before searching - */ - minQueryLength?: number - /** - * The function to use to fetch the search state - */ - fetch: SearchQuery | ((input: string) => PromiseLike) - /** - * The function to use to submit the search - */ - submit?: ( - query: string, - config: AutocompleteConfig, - options?: SearchAutocompleteOptions - ) => void - /** - * Enable history - */ - historyEnabled?: boolean - /** - * Max number of history items to show - */ - historySize?: number - /** - * Enable Nosto Analytics - */ - nostoAnalytics?: boolean - /** - * Google Analytics configuration. Set to `false` to disable. - */ - googleAnalytics?: GoogleAnalyticsConfig | boolean + /** + * The input element to attach the autocomplete to + */ + inputSelector: Selector + /** + * The dropdown element to attach the autocomplete to + */ + dropdownSelector: Selector | ((input: HTMLInputElement) => Selector) + /** + * The function to use to render the dropdown + */ + render: (container: HTMLElement, state: State) => void | PromiseLike + /** + * Minimum length of the query before searching + */ + minQueryLength?: number + /** + * The function to use to fetch the search state + */ + fetch: SearchQuery | ((input: string) => PromiseLike) + /** + * The function to use to submit the search + */ + submit?: ( + query: string, + config: AutocompleteConfig, + options?: SearchAutocompleteOptions + ) => void + /** + * Enable history + */ + historyEnabled?: boolean + /** + * Max number of history items to show + */ + historySize?: number + /** + * Enable Nosto Analytics + */ + nostoAnalytics?: boolean + /** + * Google Analytics configuration. Set to `false` to disable. + */ + googleAnalytics?: GoogleAnalyticsConfig | boolean } export const defaultGaConfig = { - serpPath: "/search", - queryParamName: "query", - enabled: true, + serpPath: "/search", + queryParamName: "query", + enabled: true, } export function getDefaultConfig() { - return { - minQueryLength: 2, - historyEnabled: true, - historySize: 5, - nostoAnalytics: true, - googleAnalytics: defaultGaConfig, - submit: (query, config, options) => { - if ( - query.length >= - (config.minQueryLength ?? - getDefaultConfig().minQueryLength) - ) { - search( - { - query, - }, - { - redirect: true, - track: config.nostoAnalytics ? "serp" : undefined, - ...options, - } - ) - } - }, - } satisfies Partial> + return { + minQueryLength: 2, + historyEnabled: true, + historySize: 5, + nostoAnalytics: true, + googleAnalytics: defaultGaConfig, + submit: (query, config, options) => { + if ( + query.length >= + (config.minQueryLength ?? getDefaultConfig().minQueryLength) + ) { + search( + { + query, + }, + { + redirect: true, + track: config.nostoAnalytics ? "serp" : undefined, + ...options, + } + ) + } + }, + } satisfies Partial> } diff --git a/src/defaults/Autocomplete.tsx b/src/defaults/Autocomplete.tsx index 9f7ed0d..97c4c01 100644 --- a/src/defaults/Autocomplete.tsx +++ b/src/defaults/Autocomplete.tsx @@ -1,20 +1,20 @@ import type { SearchKeyword, SearchProduct } from "@nosto/nosto-js/client" export interface AutocompleteProps { - /** - * The response from the autocomplete API. - */ - response?: { - keywords: { - hits: SearchKeyword[] - } - products: { - hits: SearchProduct[] - } + /** + * The response from the autocomplete API. + */ + response?: { + keywords: { + hits: SearchKeyword[] } - history?: { - item: string - }[] + products: { + hits: SearchProduct[] + } + } + history?: { + item: string + }[] } /** @@ -26,149 +26,137 @@ export interface AutocompleteProps { * @category React */ export function Autocomplete({ response, history }: AutocompleteProps) { - const hasKeywords = !!response?.keywords?.hits?.length - const hasProducts = !!response?.products?.hits?.length - const hasHistory = !!history?.length + const hasKeywords = !!response?.keywords?.hits?.length + const hasProducts = !!response?.products?.hits?.length + const hasHistory = !!history?.length - if (!hasKeywords && !hasProducts && !hasHistory) { - return null - } + if (!hasKeywords && !hasProducts && !hasHistory) { + return null + } - return ( -
- {!hasKeywords && !hasProducts && hasHistory ? ( - - ) : hasKeywords || hasProducts ? ( - <> - {hasKeywords && ( - - )} - {hasProducts && ( - - )} -
- -
- - ) : null} -
- ) + return ( +
+ {!hasKeywords && !hasProducts && hasHistory ? ( + + ) : hasKeywords || hasProducts ? ( + <> + {hasKeywords && } + {hasProducts && } +
+ +
+ + ) : null} +
+ ) } function History({ history }: { history: AutocompleteProps["history"] }) { - return ( - <> -
-
Recently searched
- {history?.map((hit, index) => { - return ( -
- {hit.item} - - ✕ - -
- ) - })} -
-
- + return ( + <> +
+
Recently searched
+ {history?.map((hit, index) => { + return ( +
+ {hit.item} + + ✕ +
- - ) + ) + })} +
+
+ +
+ + ) } function Keywords({ keywords }: { keywords: SearchKeyword[] }) { - return ( -
-
Keywords
- {keywords?.map((hit, index) => { - return ( -
- {hit._highlight && hit._highlight.keyword ? ( - - ) : ( - {hit.keyword} - )} -
- ) - })} -
- ) + return ( +
+
Keywords
+ {keywords?.map((hit, index) => { + return ( +
+ {hit._highlight && hit._highlight.keyword ? ( + + ) : ( + {hit.keyword} + )} +
+ ) + })} +
+ ) } function Products({ products }: { products: SearchProduct[] }) { - return ( - - ) + return ( + + ) } diff --git a/src/entries/liquid.ts b/src/entries/liquid.ts index 2ce3263..e0e0436 100644 --- a/src/entries/liquid.ts +++ b/src/entries/liquid.ts @@ -1,6 +1,6 @@ export { - fromLiquidTemplate, - fromRemoteLiquidTemplate, - defaultLiquidTemplate, + fromLiquidTemplate, + fromRemoteLiquidTemplate, + defaultLiquidTemplate, } from "../liquid" export * from "./base" diff --git a/src/entries/mustache.ts b/src/entries/mustache.ts index e4e3eaf..bd5674d 100644 --- a/src/entries/mustache.ts +++ b/src/entries/mustache.ts @@ -1,6 +1,6 @@ export { - fromMustacheTemplate, - fromRemoteMustacheTemplate, - defaultMustacheTemplate, + fromMustacheTemplate, + fromRemoteMustacheTemplate, + defaultMustacheTemplate, } from "../mustache" export * from "./base" diff --git a/src/liquid.ts b/src/liquid.ts index 10b3bcb..9710cd3 100644 --- a/src/liquid.ts +++ b/src/liquid.ts @@ -37,21 +37,21 @@ export { defaultLiquidTemplate } from "./defaults/_generated" * ``` */ export function fromLiquidTemplate( - template: string + template: string ): (container: HTMLElement, state: State) => PromiseLike { - const instance = Liquid ? new Liquid() : undefined + const instance = Liquid ? new Liquid() : undefined - if (instance === undefined) { - throw new Error( - "Liquid is not defined. Please include the Liquid library in your page." - ) - } + if (instance === undefined) { + throw new Error( + "Liquid is not defined. Please include the Liquid library in your page." + ) + } - return (container, state) => { - container.innerHTML = instance.parseAndRenderSync(template, state) + return (container, state) => { + container.innerHTML = instance.parseAndRenderSync(template, state) - return Promise.resolve(undefined) - } + return Promise.resolve(undefined) + } } /** @@ -69,32 +69,30 @@ export function fromLiquidTemplate( * ``` */ export function fromRemoteLiquidTemplate( - url: string + url: string ): (container: HTMLElement, state: State) => PromiseLike { - return (container, state) => { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open("GET", url) - xhr.onload = async () => { - if (xhr.status === 200) { - await fromLiquidTemplate(xhr.responseText)(container, state) - resolve(undefined) - } else { - reject( - new Error( - `Failed to fetch remote liquid template: ${xhr.statusText}` - ) - ) - } - } - xhr.onerror = () => { - reject( - new Error( - `Failed to fetch remote liquid template: ${xhr.statusText}` - ) - ) - } - xhr.send() - }) - } + return (container, state) => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("GET", url) + xhr.onload = async () => { + if (xhr.status === 200) { + await fromLiquidTemplate(xhr.responseText)(container, state) + resolve(undefined) + } else { + reject( + new Error( + `Failed to fetch remote liquid template: ${xhr.statusText}` + ) + ) + } + } + xhr.onerror = () => { + reject( + new Error(`Failed to fetch remote liquid template: ${xhr.statusText}`) + ) + } + xhr.send() + }) + } } diff --git a/src/mustache.ts b/src/mustache.ts index 64722d2..236bcc3 100644 --- a/src/mustache.ts +++ b/src/mustache.ts @@ -4,10 +4,10 @@ import Mustache from "mustache" export { defaultMustacheTemplate } from "./defaults/_generated" type Options = { - /** - * Mustache helpers to extend template functionality. - */ - helpers?: object + /** + * Mustache helpers to extend template functionality. + */ + helpers?: object } /** @@ -49,32 +49,32 @@ type Options = { * ``` */ export function fromMustacheTemplate( - template: string, - options?: Options + template: string, + options?: Options ) { - if (Mustache === undefined) { - throw new Error( - "Mustache is not defined. Please include the Mustache dependency or library in your page." - ) - } + if (Mustache === undefined) { + throw new Error( + "Mustache is not defined. Please include the Mustache dependency or library in your page." + ) + } - const { helpers } = options || {} + const { helpers } = options || {} - return (container: HTMLElement, state: State) => { - container.innerHTML = Mustache.render(template, { - ...state, - imagePlaceholder: "https://cdn.nosto.com/nosto/9/mock", - toJson: function () { - return JSON.stringify(this) - }, - showListPrice: function () { - return this.listPrice !== this.price - }, - ...helpers, - }) + return (container: HTMLElement, state: State) => { + container.innerHTML = Mustache.render(template, { + ...state, + imagePlaceholder: "https://cdn.nosto.com/nosto/9/mock", + toJson: function () { + return JSON.stringify(this) + }, + showListPrice: function () { + return this.listPrice !== this.price + }, + ...helpers, + }) - return Promise.resolve(undefined) - } + return Promise.resolve(undefined) + } } /** @@ -92,35 +92,38 @@ export function fromMustacheTemplate( * ``` */ export function fromRemoteMustacheTemplate( - url: string, - options?: { - helpers?: object - } + url: string, + options?: { + helpers?: object + } ): (container: HTMLElement, state: State) => PromiseLike { - return (container, state) => { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open("GET", url) - xhr.onload = async () => { - if (xhr.status === 200) { - await fromMustacheTemplate(xhr.responseText, options)(container, state) - resolve(undefined) - } else { - reject( - new Error( - `Failed to fetch remote mustache template: ${xhr.statusText}` - ) - ) - } - } - xhr.onerror = () => { - reject( - new Error( - `Failed to fetch remote mustache template: ${xhr.statusText}` - ) - ) - } - xhr.send() - }) - } + return (container, state) => { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open("GET", url) + xhr.onload = async () => { + if (xhr.status === 200) { + await fromMustacheTemplate(xhr.responseText, options)( + container, + state + ) + resolve(undefined) + } else { + reject( + new Error( + `Failed to fetch remote mustache template: ${xhr.statusText}` + ) + ) + } + } + xhr.onerror = () => { + reject( + new Error( + `Failed to fetch remote mustache template: ${xhr.statusText}` + ) + ) + } + xhr.send() + }) + } } diff --git a/src/search.ts b/src/search.ts index d33169f..fb5bbe2 100644 --- a/src/search.ts +++ b/src/search.ts @@ -2,56 +2,56 @@ import type { SearchOptions, SearchQuery } from "@nosto/nosto-js/client" import { getNostoClient } from "./api/client" const defaultProductFields = [ - "productId", - "url", - "name", - "imageUrl", - "thumbUrl", - "description", - "brand", - "variantId", - "availability", - "price", - "priceText", - "categoryIds", - "categories", - "customFields.key", - "customFields.value", - "priceCurrencyCode", - "datePublished", - "listPrice", - "unitPricingBaseMeasure", - "unitPricingUnit", - "unitPricingMeasure", - "googleCategory", - "gtin", - "ageGroup", - "gender", - "condition", - "alternateImageUrls", - "ratingValue", - "reviewCount", - "inventoryLevel", - "skus.id", - "skus.name", - "skus.price", - "skus.listPrice", - "skus.priceText", - "skus.url", - "skus.imageUrl", - "skus.inventoryLevel", - "skus.customFields.key", - "skus.customFields.value", - "skus.availability", - "pid", - "onDiscount", - "extra.key", - "extra.value", - "saleable", - "available", - "tags1", - "tags2", - "tags3", + "productId", + "url", + "name", + "imageUrl", + "thumbUrl", + "description", + "brand", + "variantId", + "availability", + "price", + "priceText", + "categoryIds", + "categories", + "customFields.key", + "customFields.value", + "priceCurrencyCode", + "datePublished", + "listPrice", + "unitPricingBaseMeasure", + "unitPricingUnit", + "unitPricingMeasure", + "googleCategory", + "gtin", + "ageGroup", + "gender", + "condition", + "alternateImageUrls", + "ratingValue", + "reviewCount", + "inventoryLevel", + "skus.id", + "skus.name", + "skus.price", + "skus.listPrice", + "skus.priceText", + "skus.url", + "skus.imageUrl", + "skus.inventoryLevel", + "skus.customFields.key", + "skus.customFields.value", + "skus.availability", + "pid", + "onDiscount", + "extra.key", + "extra.value", + "saleable", + "available", + "tags1", + "tags2", + "tags3", ] /** @@ -78,31 +78,28 @@ const defaultProductFields = [ * }) * ``` */ -export async function search( - query: SearchQuery, - options?: SearchOptions -) { - const { redirect = false, track, isKeyword = false } = options ?? {} +export async function search(query: SearchQuery, options?: SearchOptions) { + const { redirect = false, track, isKeyword = false } = options ?? {} - const fields = query.products?.fields ?? defaultProductFields - const facets = query.products?.facets ?? ["*"] - const size = query.products?.size ?? 20 - const from = query.products?.from ?? 0 + const fields = query.products?.fields ?? defaultProductFields + const facets = query.products?.facets ?? ["*"] + const size = query.products?.size ?? 20 + const from = query.products?.from ?? 0 - const api = await getNostoClient() - const response = await api.search( - { - ...query, - products: { - ...query.products, - fields, - facets, - size, - from, - }, - }, - { redirect, track, isKeyword } - ) + const api = await getNostoClient() + const response = await api.search( + { + ...query, + products: { + ...query.products, + fields, + facets, + size, + from, + }, + }, + { redirect, track, isKeyword } + ) - return { query, response } + return { query, response } } diff --git a/src/shims.d.ts b/src/shims.d.ts index 42e67c5..8739550 100644 --- a/src/shims.d.ts +++ b/src/shims.d.ts @@ -1,12 +1,12 @@ type GaNamespace = { - (): void - getAll(): { - send(type: string, url: string): void - }[] + (): void + getAll(): { + send(type: string, url: string): void + }[] } interface Window { - ga?: GaNamespace - gtag?: unknown - google_tag_manager?: unknown -} \ No newline at end of file + ga?: GaNamespace + gtag?: unknown + google_tag_manager?: unknown +} diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 6e79046..38ea207 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,73 +1,76 @@ type Selector = string | Element export function findAll( - selector: Selector, - filterType?: { new (): T } + selector: Selector, + filterType?: { new (): T } ): T[] { - const elements = typeof selector === "string" ? Array.from(document.querySelectorAll(selector)) : [selector] - return elements.filter((v): v is T => - filterType ? v instanceof filterType : true - ) + const elements = + typeof selector === "string" + ? Array.from(document.querySelectorAll(selector)) + : [selector] + return elements.filter((v): v is T => + filterType ? v instanceof filterType : true + ) } export async function DOMReady(): Promise { - return new Promise(resolve => { - if (document.readyState !== "loading") { - resolve() - } else { - window.addEventListener("DOMContentLoaded", () => { - resolve() - }) - } - }) + return new Promise(resolve => { + if (document.readyState !== "loading") { + resolve() + } else { + window.addEventListener("DOMContentLoaded", () => { + resolve() + }) + } + }) } export function parents(target: Selector, selector?: string): Element[] { - let parentList: Element[] = [] - findAll(target).forEach(element => { - const parent = element.parentNode - if (parent !== document && parent instanceof Element) { - parentList.push(parent) - parentList = parentList.concat(parents(parent)) - } - }) - return parentList.filter( - element => selector === undefined || matches(element, selector) - ) + let parentList: Element[] = [] + findAll(target).forEach(element => { + const parent = element.parentNode + if (parent !== document && parent instanceof Element) { + parentList.push(parent) + parentList = parentList.concat(parents(parent)) + } + }) + return parentList.filter( + element => selector === undefined || matches(element, selector) + ) } export function matches(target: Selector, selector: string): boolean { - const matchesFunc = - Element.prototype.matches || - // @ts-expect-error proprietary method - Element.prototype.msMatchesSelector || - Element.prototype.webkitMatchesSelector - return findAll(target).some(element => matchesFunc.call(element, selector)) + const matchesFunc = + Element.prototype.matches || + // @ts-expect-error proprietary method + Element.prototype.msMatchesSelector || + Element.prototype.webkitMatchesSelector + return findAll(target).some(element => matchesFunc.call(element, selector)) } export function bindClickOutside( - [element, input]: Array, - callback: () => void + [element, input]: Array, + callback: () => void ) { - const onClick = (event: MouseEvent) => { - const target = event.target + const onClick = (event: MouseEvent) => { + const target = event.target - if (target instanceof HTMLElement && element) { - if ( - target !== element && - target !== input && - !parents(target).includes(element) - ) { - callback() - } - } + if (target instanceof HTMLElement && element) { + if ( + target !== element && + target !== input && + !parents(target).includes(element) + ) { + callback() + } } + } - document.addEventListener("click", onClick) + document.addEventListener("click", onClick) - return { - destroy: () => { - document.removeEventListener("click", onClick) - }, - } + return { + destroy: () => { + document.removeEventListener("click", onClick) + }, + } } diff --git a/src/utils/dropdown.ts b/src/utils/dropdown.ts index e82fb54..d8fcbbb 100644 --- a/src/utils/dropdown.ts +++ b/src/utils/dropdown.ts @@ -2,280 +2,276 @@ import { log } from "../api/client" import { SearchAutocompleteOptions } from "../autocomplete" type OnClickBindings = { - [key: string]: (obj: { - data: string | undefined - el: HTMLElement - update: (state: State) => void - }) => unknown + [key: string]: (obj: { + data: string | undefined + el: HTMLElement + update: (state: State) => void + }) => unknown } export function createDropdown( - container: HTMLElement, - initialState: PromiseLike, - render: (container: HTMLElement, state: State) => void | PromiseLike, - submit: ( - inputValue: string, - options?: SearchAutocompleteOptions - ) => unknown, - updateInput: (inputValue: string) => void, - onClickBindings?: OnClickBindings + container: HTMLElement, + initialState: PromiseLike, + render: (container: HTMLElement, state: State) => void | PromiseLike, + submit: (inputValue: string, options?: SearchAutocompleteOptions) => unknown, + updateInput: (inputValue: string) => void, + onClickBindings?: OnClickBindings ) { - let elements: HTMLElement[] = [] - let unbindCallbacks: Array<() => void> = [] + let elements: HTMLElement[] = [] + let unbindCallbacks: Array<() => void> = [] - let isEmpty: boolean = true - let selectedIndex: number = -1 + let isEmpty: boolean = true + let selectedIndex: number = -1 - function handleElementSubmit(el: HTMLElement): void { - const hit = el?.dataset?.nsHit + function handleElementSubmit(el: HTMLElement): void { + const hit = el?.dataset?.nsHit - if (hit) { - const parsedHit = parseHit(hit) - hide() + if (hit) { + const parsedHit = parseHit(hit) + hide() - if (parsedHit?.item) { - submit(parsedHit.item) - return - } + if (parsedHit?.item) { + submit(parsedHit.item) + return + } - if (parsedHit?.keyword) { - submit(parsedHit.keyword, { redirect: !!parsedHit?._redirect, isKeyword: true }) - - if (parsedHit?._redirect) { - location.href = parsedHit._redirect - } - return - } + if (parsedHit?.keyword) { + submit(parsedHit.keyword, { + redirect: !!parsedHit?._redirect, + isKeyword: true, + }) - if (parsedHit?.url) { - location.href = parsedHit.url - } + if (parsedHit?._redirect) { + location.href = parsedHit._redirect } - } + return + } - function loadElements() { - isEmpty = !container.innerHTML.trim() - - if (!isEmpty) { - elements = Array.from( - container.querySelectorAll("[data-ns-hit]") - ).map(el => { - bindElementSubmit(el) - return el - }) - } + if (parsedHit?.url) { + location.href = parsedHit.url + } } - - function bindDataCallbacks() { - Object.entries(onClickBindings ?? {}).map(([key, callback]) => { - // Convert camelCase to kebab-case - const dataKey = `[data-ns-${key - .replace(/([A-Z])/g, "-$1") - .toLowerCase()}]` - - Array.from(container.querySelectorAll(dataKey)).map( - el => { - const data = - el?.dataset?.[ - `ns${key.charAt(0).toUpperCase() + key.slice(1)}` - ] - const onClick = () => { - callback({ - data, - el, - update, - }) - } - - el.addEventListener("click", onClick) - unbindCallbacks.push(() => { - el.removeEventListener("click", onClick) - }) - } - ) - }) + } + + function loadElements() { + isEmpty = !container.innerHTML.trim() + + if (!isEmpty) { + elements = Array.from( + container.querySelectorAll("[data-ns-hit]") + ).map(el => { + bindElementSubmit(el) + return el + }) } - - function bindElementSubmit(el: HTMLElement) { - const onSubmit = () => { - handleElementSubmit(el) + } + + function bindDataCallbacks() { + Object.entries(onClickBindings ?? {}).map(([key, callback]) => { + // Convert camelCase to kebab-case + const dataKey = `[data-ns-${key + .replace(/([A-Z])/g, "-$1") + .toLowerCase()}]` + + Array.from(container.querySelectorAll(dataKey)).map(el => { + const data = + el?.dataset?.[`ns${key.charAt(0).toUpperCase() + key.slice(1)}`] + const onClick = () => { + callback({ + data, + el, + update, + }) } - el.addEventListener("click", onSubmit) + el.addEventListener("click", onClick) unbindCallbacks.push(() => { - el.removeEventListener("click", onSubmit) + el.removeEventListener("click", onClick) }) - } + }) + }) + } - function highlight(index: number, prevIndex?: number) { - if (typeof prevIndex === "number" && elements[prevIndex]) { - elements[prevIndex].classList.remove("selected") - } - - if (typeof index === "number" && elements[index]) { - elements[index]?.classList.add("selected") - - const hit = elements[index]?.dataset?.nsHit - - if (hit) { - const parsedHit = parseHit(hit) + function bindElementSubmit(el: HTMLElement) { + const onSubmit = () => { + handleElementSubmit(el) + } - if (parsedHit.item) { - updateInput(parsedHit.item) - return - } + el.addEventListener("click", onSubmit) + unbindCallbacks.push(() => { + el.removeEventListener("click", onSubmit) + }) + } - if (parsedHit.keyword) { - updateInput(parsedHit.keyword) - return - } - } - } + function highlight(index: number, prevIndex?: number) { + if (typeof prevIndex === "number" && elements[prevIndex]) { + elements[prevIndex].classList.remove("selected") } - function dispose() { - resetHighlight() - elements = [] - unbindCallbacks.forEach(v => v()) - unbindCallbacks = [] - } + if (typeof index === "number" && elements[index]) { + elements[index]?.classList.add("selected") - async function update(state: State) { - dispose() - await Promise.resolve(render(container, state)) + const hit = elements[index]?.dataset?.nsHit - // Without setTimeout React does not have committed DOM changes yet, so we don't have the correct elements. - setTimeout(() => { - loadElements() - bindDataCallbacks() - show() - }, 0) - } + if (hit) { + const parsedHit = parseHit(hit) - function hide() { - resetHighlight() - container.style.display = "none" - } + if (parsedHit.item) { + updateInput(parsedHit.item) + return + } - function show() { - if (!isEmpty) { - container.style.display = "" - } else { - hide() + if (parsedHit.keyword) { + updateInput(parsedHit.keyword) + return } + } } - - function clear() { - dispose() - isEmpty = true - hide() + } + + function dispose() { + resetHighlight() + elements = [] + unbindCallbacks.forEach(v => v()) + unbindCallbacks = [] + } + + async function update(state: State) { + dispose() + await Promise.resolve(render(container, state)) + + // Without setTimeout React does not have committed DOM changes yet, so we don't have the correct elements. + setTimeout(() => { + loadElements() + bindDataCallbacks() + show() + }, 0) + } + + function hide() { + resetHighlight() + container.style.display = "none" + } + + function show() { + if (!isEmpty) { + container.style.display = "" + } else { + hide() } + } - function isOpen() { - return container.style.display !== "none" - } + function clear() { + dispose() + isEmpty = true + hide() + } - function goDown() { - let prevIndex = selectedIndex + function isOpen() { + return container.style.display !== "none" + } - if (selectedIndex === elements.length - 1) { - selectedIndex = 0 - } else { - prevIndex = selectedIndex++ - } + function goDown() { + let prevIndex = selectedIndex - highlight(selectedIndex, prevIndex) + if (selectedIndex === elements.length - 1) { + selectedIndex = 0 + } else { + prevIndex = selectedIndex++ } - function goUp() { - if (hasHighlight()) { - let prevIndex = selectedIndex - - if (selectedIndex === 0) { - selectedIndex = elements.length - 1 - } else { - prevIndex = selectedIndex-- - } - - highlight(selectedIndex, prevIndex) - } else { - selectedIndex = elements.length - 1 - highlight(selectedIndex) - } - } + highlight(selectedIndex, prevIndex) + } - function handleSubmit() { - if (isOpen() && hasHighlight() && elements[selectedIndex]) { - handleElementSubmit(elements[selectedIndex]) - } - } + function goUp() { + if (hasHighlight()) { + let prevIndex = selectedIndex - function hasHighlight() { - return selectedIndex > -1 - } + if (selectedIndex === 0) { + selectedIndex = elements.length - 1 + } else { + prevIndex = selectedIndex-- + } - function getHighlight() { - return elements[selectedIndex] + highlight(selectedIndex, prevIndex) + } else { + selectedIndex = elements.length - 1 + highlight(selectedIndex) } + } - function resetHighlight() { - if (hasHighlight()) { - elements[selectedIndex]?.classList.remove("selected") - selectedIndex = -1 - } + function handleSubmit() { + if (isOpen() && hasHighlight() && elements[selectedIndex]) { + handleElementSubmit(elements[selectedIndex]) } + } - function destroy() { - dispose() - isEmpty = true - container.innerHTML = "" - } + function hasHighlight() { + return selectedIndex > -1 + } - async function init() { - const state = await Promise.resolve(initialState) - await Promise.resolve(render(container, state)) + function getHighlight() { + return elements[selectedIndex] + } - // Without setTimeout React does not have committed DOM changes yet, so we don't have the correct elements. - setTimeout(() => { - loadElements() - bindDataCallbacks() - hide() - }, 0) - } - init() - - return { - update, - clear, - isOpen, - goDown, - goUp, - handleSubmit, - destroy, - show, - hide, - resetHighlight, - hasHighlight, - getHighlight, - container, + function resetHighlight() { + if (hasHighlight()) { + elements[selectedIndex]?.classList.remove("selected") + selectedIndex = -1 } + } + + function destroy() { + dispose() + isEmpty = true + container.innerHTML = "" + } + + async function init() { + const state = await Promise.resolve(initialState) + await Promise.resolve(render(container, state)) + + // Without setTimeout React does not have committed DOM changes yet, so we don't have the correct elements. + setTimeout(() => { + loadElements() + bindDataCallbacks() + hide() + }, 0) + } + init() + + return { + update, + clear, + isOpen, + goDown, + goUp, + handleSubmit, + destroy, + show, + hide, + resetHighlight, + hasHighlight, + getHighlight, + container, + } } export type Dropdown = ReturnType> interface Hit { - item?: string - keyword?: string - url?: string - _redirect?: string + item?: string + keyword?: string + url?: string + _redirect?: string } export function parseHit(hit: string): Hit { - try { - const parsedHit: Hit | undefined | null = JSON.parse(hit) - return parsedHit ?? {} - } catch (error) { - log("warn", "Could not parse hit", error) - return {} - } + try { + const parsedHit: Hit | undefined | null = JSON.parse(hit) + return parsedHit ?? {} + } catch (error) { + log("warn", "Could not parse hit", error) + return {} + } } diff --git a/src/utils/ga.ts b/src/utils/ga.ts index da572b1..16d1449 100644 --- a/src/utils/ga.ts +++ b/src/utils/ga.ts @@ -4,120 +4,116 @@ import { AutocompleteConfig, defaultGaConfig } from "../config" const localStorageKey = "nostoAutocomplete:gaEvent" export function trackGaPageView(options?: { - delay?: boolean - title?: string - location?: string + delay?: boolean + title?: string + location?: string }) { - const { - delay = false, - title = document.title, - location = window.location.href, - } = options || {} + const { + delay = false, + title = document.title, + location = window.location.href, + } = options || {} - if (delay) { - saveToLocalStorage(title, location) - } else { - if ("gtag" in window && typeof window.gtag === "function") { - const accounts = - "google_tag_manager" in window && - typeof window.google_tag_manager === "object" - ? Object.keys(window.google_tag_manager || []).filter( - e => { - return e.substring(0, 2) == "G-" - } - ) - : [] + if (delay) { + saveToLocalStorage(title, location) + } else { + if ("gtag" in window && typeof window.gtag === "function") { + const accounts = + "google_tag_manager" in window && + typeof window.google_tag_manager === "object" + ? Object.keys(window.google_tag_manager || []).filter(e => { + return e.substring(0, 2) == "G-" + }) + : [] - if (accounts.length > 1) { - for (let i = 0; i < accounts.length; i++) { - window.gtag("event", "page_view", { - page_title: title, - page_location: location, - send_to: accounts[i], - }) - } - } else { - window.gtag("event", "page_view", { - page_title: title, - page_location: location, - }) - } + if (accounts.length > 1) { + for (let i = 0; i < accounts.length; i++) { + window.gtag("event", "page_view", { + page_title: title, + page_location: location, + send_to: accounts[i], + }) } - if ( - "ga" in window && - typeof window.ga === "function" && - "getAll" in window.ga && - typeof window.ga.getAll === "function" - ) { - try { - const url = new URL(location) - const trackers = window.ga!.getAll() - if (trackers?.length > 0) { - trackers[0]?.send("pageview", url.pathname + url.search) - } - } catch (error) { - log("warn", "Could not send pageview to GA", error) - } + } else { + window.gtag("event", "page_view", { + page_title: title, + page_location: location, + }) + } + } + if ( + "ga" in window && + typeof window.ga === "function" && + "getAll" in window.ga && + typeof window.ga.getAll === "function" + ) { + try { + const url = new URL(location) + const trackers = window.ga!.getAll() + if (trackers?.length > 0) { + trackers[0]?.send("pageview", url.pathname + url.search) } + } catch (error) { + log("warn", "Could not send pageview to GA", error) + } } + } } export const isGaEnabled = (config: AutocompleteConfig) => - typeof config.googleAnalytics === "boolean" - ? config.googleAnalytics - : typeof config.googleAnalytics === "object" && - config.googleAnalytics.enabled + typeof config.googleAnalytics === "boolean" + ? config.googleAnalytics + : typeof config.googleAnalytics === "object" && + config.googleAnalytics.enabled export const getGaTrackUrl = ( - value: string | undefined, - config: AutocompleteConfig + value: string | undefined, + config: AutocompleteConfig ) => { - const gaConfig = isGaEnabled(config) - ? typeof config.googleAnalytics === "boolean" - ? defaultGaConfig - : { - ...defaultGaConfig, - ...config.googleAnalytics, - } - : undefined - - if (value && gaConfig) { - try { - return new URL( - `${ - gaConfig?.serpPath || location.pathname - }?${`${encodeURIComponent( - gaConfig.queryParamName - )}=${encodeURIComponent(value).replace(/%20/g, "+")}`}`, - window.location.origin - ).toString() - } catch (error) { - log("warn", "Could not create track url", error) - return undefined + const gaConfig = isGaEnabled(config) + ? typeof config.googleAnalytics === "boolean" + ? defaultGaConfig + : { + ...defaultGaConfig, + ...config.googleAnalytics, } + : undefined + + if (value && gaConfig) { + try { + return new URL( + `${gaConfig?.serpPath || location.pathname}?${`${encodeURIComponent( + gaConfig.queryParamName + )}=${encodeURIComponent(value).replace(/%20/g, "+")}`}`, + window.location.origin + ).toString() + } catch (error) { + log("warn", "Could not create track url", error) + return undefined } + } } function saveToLocalStorage(title: string, location: string): void { - localStorage.setItem(localStorageKey, JSON.stringify({ title, location })) + localStorage.setItem(localStorageKey, JSON.stringify({ title, location })) } interface Event { - title: string - location: string + title: string + location: string } function consumeLocalStorageEvent(): void { - const eventString = localStorage.getItem(localStorageKey) - if (typeof eventString === "string") { - localStorage.removeItem(localStorageKey) - try { - const event: Event = JSON.parse(eventString) - trackGaPageView(event) - } catch (e) { - log("warn", "Could not consume pageView", e) - } + const eventString = localStorage.getItem(localStorageKey) + if (typeof eventString === "string") { + localStorage.removeItem(localStorageKey) + try { + const event: Event = JSON.parse(eventString) + trackGaPageView(event) + } catch (e) { + log("warn", "Could not consume pageView", e) } + } } setTimeout(consumeLocalStorageEvent, 1000) diff --git a/src/utils/history.ts b/src/utils/history.ts index 2014ed8..6a78619 100644 --- a/src/utils/history.ts +++ b/src/utils/history.ts @@ -4,55 +4,53 @@ import { DefaultState } from "./state" type Items = NonNullable export function createHistory(size: number) { - const localStorageKey = "nosto:autocomplete:history" - let items = get() - - function get(): Items { - try { - return ( - JSON.parse(localStorage.getItem(localStorageKey) ?? "[]") ?? [] - ) - } catch (err) { - log("error", "Could not get history items.", err) - return [] - } + const localStorageKey = "nosto:autocomplete:history" + let items = get() + + function get(): Items { + try { + return JSON.parse(localStorage.getItem(localStorageKey) ?? "[]") ?? [] + } catch (err) { + log("error", "Could not get history items.", err) + return [] } + } - function set(data: Items) { - try { - localStorage.setItem(localStorageKey, JSON.stringify(data)) - } catch (err) { - log("error", "Could not set history items.", err) - } - } - - function add(item: string) { - set( - (items = [ - { item }, - ...(items?.filter(v => v.item !== item) || []), - ].slice(0, size)) - ) - } - - function clear() { - set((items = [])) - } - - function remove(item: string) { - set((items = items.filter(v => v.item !== item))) - } - - function getItems() { - return items - } - - return { - add, - clear, - remove, - getItems, + function set(data: Items) { + try { + localStorage.setItem(localStorageKey, JSON.stringify(data)) + } catch (err) { + log("error", "Could not set history items.", err) } + } + + function add(item: string) { + set( + (items = [{ item }, ...(items?.filter(v => v.item !== item) || [])].slice( + 0, + size + )) + ) + } + + function clear() { + set((items = [])) + } + + function remove(item: string) { + set((items = items.filter(v => v.item !== item))) + } + + function getItems() { + return items + } + + return { + add, + clear, + remove, + getItems, + } } export type History = ReturnType diff --git a/src/utils/input.ts b/src/utils/input.ts index c051191..5af286f 100644 --- a/src/utils/input.ts +++ b/src/utils/input.ts @@ -1,102 +1,102 @@ import { findAll } from "./dom" type Callbacks = { - onSubmit?: (value: string) => void - onInput?: (value: string) => void - onFocus?: (value: string) => void - onBlur?: (value: string) => void - onKeyDown?: (value: string, key: string) => void - onClick?: (value: string) => void + onSubmit?: (value: string) => void + onInput?: (value: string) => void + onFocus?: (value: string) => void + onBlur?: (value: string) => void + onKeyDown?: (value: string, key: string) => void + onClick?: (value: string) => void } export function bindInput( - selector: string | HTMLInputElement, - callbacks: Callbacks + selector: string | HTMLInputElement, + callbacks: Callbacks ): { - destroy: () => void + destroy: () => void } { - const target = - selector instanceof HTMLInputElement - ? [selector] - : findAll(selector, HTMLInputElement) - const unbindCallbacks = target.flatMap(el => { - const cbs: Array<() => void> = [] + const target = + selector instanceof HTMLInputElement + ? [selector] + : findAll(selector, HTMLInputElement) + const unbindCallbacks = target.flatMap(el => { + const cbs: Array<() => void> = [] - if (callbacks.onSubmit) { - const onKeyDown = (event: KeyboardEvent) => { - callbacks.onKeyDown?.(el.value, event.key) - if ( - event.key === "ArrowDown" || - event.key === "ArrowUp" || - event.key === "Enter" - ) { - event.preventDefault() - } - } - - el.addEventListener("keydown", onKeyDown) - cbs.push(() => { - el.removeEventListener("keydown", onKeyDown) - }) - - const form = el.form + if (callbacks.onSubmit) { + const onKeyDown = (event: KeyboardEvent) => { + callbacks.onKeyDown?.(el.value, event.key) + if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Enter" + ) { + event.preventDefault() + } + } - if (form) { - const onSubmit = (event: SubmitEvent) => { - event.preventDefault() - callbacks.onSubmit?.(el.value) - } - form.addEventListener("submit", onSubmit) - cbs.push(() => { - form?.removeEventListener("submit", onSubmit) - }) + el.addEventListener("keydown", onKeyDown) + cbs.push(() => { + el.removeEventListener("keydown", onKeyDown) + }) - const buttons = Array.from(form.querySelectorAll("[type=submit]")) - buttons.forEach(button => { - const onClick = (event: Event) => { - event.preventDefault() - callbacks.onSubmit?.(el.value) - } + const form = el.form - button.addEventListener("click", onClick) - cbs.push(() => { - button.removeEventListener("click", onClick) - }) - }) - } + if (form) { + const onSubmit = (event: SubmitEvent) => { + event.preventDefault() + callbacks.onSubmit?.(el.value) } + form.addEventListener("submit", onSubmit) + cbs.push(() => { + form?.removeEventListener("submit", onSubmit) + }) - if (callbacks.onClick) { - const onClick = () => { - callbacks.onClick?.(el.value) - } - el.addEventListener("click", onClick) - } + const buttons = Array.from(form.querySelectorAll("[type=submit]")) + buttons.forEach(button => { + const onClick = (event: Event) => { + event.preventDefault() + callbacks.onSubmit?.(el.value) + } - if (callbacks.onFocus) { - const onFocus = () => { - callbacks.onFocus?.(el.value) - } - el.addEventListener("focus", onFocus) - } + button.addEventListener("click", onClick) + cbs.push(() => { + button.removeEventListener("click", onClick) + }) + }) + } + } - if (callbacks.onInput) { - const onInput = () => { - callbacks.onInput?.(el.value) - } + if (callbacks.onClick) { + const onClick = () => { + callbacks.onClick?.(el.value) + } + el.addEventListener("click", onClick) + } - el.addEventListener("input", onInput) - cbs.push(() => { - el.removeEventListener("input", onInput) - }) - } + if (callbacks.onFocus) { + const onFocus = () => { + callbacks.onFocus?.(el.value) + } + el.addEventListener("focus", onFocus) + } - return cbs - }) + if (callbacks.onInput) { + const onInput = () => { + callbacks.onInput?.(el.value) + } - return { - destroy() { - unbindCallbacks.forEach(v => v()) - }, + el.addEventListener("input", onInput) + cbs.push(() => { + el.removeEventListener("input", onInput) + }) } + + return cbs + }) + + return { + destroy() { + unbindCallbacks.forEach(v => v()) + }, + } } diff --git a/src/utils/limiter.ts b/src/utils/limiter.ts index 4e5690c..074ee01 100644 --- a/src/utils/limiter.ts +++ b/src/utils/limiter.ts @@ -1,93 +1,93 @@ type Callback = () => PromiseLike interface Event { - getPromise?: Callback - resolve: (result: T | PromiseLike) => void - reject: (...args: unknown[]) => void - number: number + getPromise?: Callback + resolve: (result: T | PromiseLike) => void + reject: (...args: unknown[]) => void + number: number } export class LimiterError extends Error {} export function createLimiter( - interval: number, - noLimitCount: number + interval: number, + noLimitCount: number ) { - let currentNumber = 0 - let lastCompletedNumber = 0 - let events: number[] = [] - let timeout: number | undefined - let lastEvent: Event | undefined + let currentNumber = 0 + let lastCompletedNumber = 0 + let events: number[] = [] + let timeout: number | undefined + let lastEvent: Event | undefined - function limited(getPromise?: Callback): PromiseLike { - return new Promise((resolve, reject) => { - currentNumber += 1 - const event: Event = { - getPromise, - resolve, - reject, - number: currentNumber, - } - removeOldEvents() - if (events.length < noLimitCount) { - execute(event) - } else { - if (lastEvent !== undefined) { - lastEvent.reject(new LimiterError("rate limit exceeded")) - } - lastEvent = event - if (timeout === undefined) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - timeout = setTimeout(() => { - timeoutAction() - }, interval) - } - } - }) - } - - function stop() { - if (timeout !== undefined) { - clearTimeout(timeout) + function limited(getPromise?: Callback): PromiseLike { + return new Promise((resolve, reject) => { + currentNumber += 1 + const event: Event = { + getPromise, + resolve, + reject, + number: currentNumber, + } + removeOldEvents() + if (events.length < noLimitCount) { + execute(event) + } else { + if (lastEvent !== undefined) { + lastEvent.reject(new LimiterError("rate limit exceeded")) } - timeout = undefined - } + lastEvent = event + if (timeout === undefined) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + timeout = setTimeout(() => { + timeoutAction() + }, interval) + } + } + }) + } - function removeOldEvents() { - const t = new Date().getTime() - interval * noLimitCount - events = events.filter(v => v >= t) + function stop() { + if (timeout !== undefined) { + clearTimeout(timeout) } + timeout = undefined + } - function timeoutAction() { - if (lastEvent !== undefined) { - execute(lastEvent) - lastEvent = undefined - stop() - } + function removeOldEvents() { + const t = new Date().getTime() - interval * noLimitCount + events = events.filter(v => v >= t) + } + + function timeoutAction() { + if (lastEvent !== undefined) { + execute(lastEvent) + lastEvent = undefined + stop() } + } - async function execute(event: Event) { - events.push(new Date().getTime()) - if (event.getPromise !== undefined) { - try { - const result = await event.getPromise() - if (event.number > lastCompletedNumber) { - lastCompletedNumber = event.number - event.resolve(result) - } else { - event.reject(new LimiterError("Got newer event")) - } - } catch (e) { - event.reject(e) - } + async function execute(event: Event) { + events.push(new Date().getTime()) + if (event.getPromise !== undefined) { + try { + const result = await event.getPromise() + if (event.number > lastCompletedNumber) { + lastCompletedNumber = event.number + event.resolve(result) } else { - // @ts-expect-error no result given - event.resolve() + event.reject(new LimiterError("Got newer event")) } + } catch (e) { + event.reject(e) + } + } else { + // @ts-expect-error no result given + event.resolve() } + } - return { - limited, - } + return { + limited, + } } diff --git a/src/utils/promise.ts b/src/utils/promise.ts index c5456a6..1a49afe 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -3,28 +3,26 @@ export type Cancellable = { promise: PromiseLike; cancel: () => void } export class CancellableError extends Error {} export function makeCancellable(promise: PromiseLike): Cancellable { - let hasCanceled_ = false + let hasCanceled_ = false - const wrappedPromise = new Promise((resolve, reject) => { - // eslint-disable-next-line promise/prefer-await-to-then - return promise.then( - val => - hasCanceled_ - ? reject(new CancellableError("cancelled promise")) - : resolve(val) - , - error => - hasCanceled_ - ? reject(new CancellableError("cancelled promise")) - : reject(error) - - ) - }) + const wrappedPromise = new Promise((resolve, reject) => { + // eslint-disable-next-line promise/prefer-await-to-then + return promise.then( + val => + hasCanceled_ + ? reject(new CancellableError("cancelled promise")) + : resolve(val), + error => + hasCanceled_ + ? reject(new CancellableError("cancelled promise")) + : reject(error) + ) + }) - return { - promise: wrappedPromise, - cancel() { - hasCanceled_ = true - }, - } + return { + promise: wrappedPromise, + cancel() { + hasCanceled_ = true + }, + } } diff --git a/src/utils/state.ts b/src/utils/state.ts index c589f4d..940ecff 100644 --- a/src/utils/state.ts +++ b/src/utils/state.ts @@ -10,112 +10,115 @@ import type { InputSearchQuery, SearchResult } from "@nosto/nosto-js/client" * @category Core */ export interface DefaultState { - /** - * The current search query object. - */ - query?: InputSearchQuery - /** - * The current search response. - */ - response?: SearchResult - /** - * The history items. - */ - history?: { item: string }[] + /** + * The current search query object. + */ + query?: InputSearchQuery + /** + * The current search response. + */ + response?: SearchResult + /** + * The history items. + */ + history?: { item: string }[] } export type StateActions = { - updateState(inputValue?: string): PromiseLike - addHistoryItem(item: string): PromiseLike - removeHistoryItem(item: string): PromiseLike - clearHistory(): PromiseLike + updateState(inputValue?: string): PromiseLike + addHistoryItem(item: string): PromiseLike + removeHistoryItem(item: string): PromiseLike + clearHistory(): PromiseLike } export function getStateActions({ - config, - history, - input, + config, + history, + input, }: { - config: Required> - history?: History - input: HTMLInputElement + config: Required> + history?: History + input: HTMLInputElement }): StateActions { - let cancellable: Cancellable | undefined + let cancellable: Cancellable | undefined - const fetchState = ( - value: string, - config: AutocompleteConfig, - options?: SearchAutocompleteOptions - ): PromiseLike => { - if (typeof config.fetch === "function") { - return config.fetch(value) - } else { - // @ts-expect-error type mismatch - return search( - { - query: value, - ...config.fetch, - }, - { - track: config.nostoAnalytics ? "autocomplete" : undefined, - redirect: false, - ...options, - } - ) + const fetchState = ( + value: string, + config: AutocompleteConfig, + options?: SearchAutocompleteOptions + ): PromiseLike => { + if (typeof config.fetch === "function") { + return config.fetch(value) + } else { + // @ts-expect-error type mismatch + return search( + { + query: value, + ...config.fetch, + }, + { + track: config.nostoAnalytics ? "autocomplete" : undefined, + redirect: false, + ...options, } + ) } + } - function getHistoryState(query: string): PromiseLike { - // @ts-expect-error type mismatch - return Promise.resolve({ - query: { - query, - }, - history: history?.getItems(), - }) - } - - function updateState(inputValue?: string, options?: SearchAutocompleteOptions): PromiseLike { - cancellable?.cancel() + function getHistoryState(query: string): PromiseLike { + // @ts-expect-error type mismatch + return Promise.resolve({ + query: { + query, + }, + history: history?.getItems(), + }) + } - if (inputValue && inputValue.length >= config.minQueryLength) { - cancellable = makeCancellable(fetchState(inputValue, config, options)) - return cancellable.promise - } else if (history) { - return getHistoryState(inputValue ?? "") - } + function updateState( + inputValue?: string, + options?: SearchAutocompleteOptions + ): PromiseLike { + cancellable?.cancel() - return ( - // @ts-expect-error type mismatch - cancellable?.promise ?? Promise.resolve({}) - ) + if (inputValue && inputValue.length >= config.minQueryLength) { + cancellable = makeCancellable(fetchState(inputValue, config, options)) + return cancellable.promise + } else if (history) { + return getHistoryState(inputValue ?? "") } - function addHistoryItem(item: string): PromiseLike { - if (history) { - history.add(item) - } - return getHistoryState(input.value) - } + return ( + // @ts-expect-error type mismatch + cancellable?.promise ?? Promise.resolve({}) + ) + } - function removeHistoryItem(item: string): PromiseLike { - if (history) { - history.remove(item) - } - return getHistoryState(input.value) + function addHistoryItem(item: string): PromiseLike { + if (history) { + history.add(item) } + return getHistoryState(input.value) + } - function clearHistory(): PromiseLike { - if (history) { - history.clear() - } - return getHistoryState(input.value) + function removeHistoryItem(item: string): PromiseLike { + if (history) { + history.remove(item) } + return getHistoryState(input.value) + } - return { - updateState, - addHistoryItem, - removeHistoryItem, - clearHistory, + function clearHistory(): PromiseLike { + if (history) { + history.clear() } + return getHistoryState(input.value) + } + + return { + updateState, + addHistoryItem, + removeHistoryItem, + clearHistory, + } }