From 9155d93d6546661f3168163fcd70820a4623ea2a Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Fri, 26 Jun 2026 11:48:58 +0100 Subject: [PATCH] feature: autocomplete forms --- dist/flux.js | 214 +++++++++++++++++++- example/08-search.php | 35 ++++ example/08a-search-results.php | 100 ++++++++++ src/AutocompleteHandler.es6 | 237 ++++++++++++++++++++++ src/DirectiveRegistry.es6 | 8 + src/Flux.es6 | 10 + src/NavigationController.es6 | 10 +- test/Flux.test.js | 251 ++++++++++++++++++++++++ test/behat/08-search.feature | 15 ++ test/behat/bootstrap/FeatureContext.php | 88 +++++++++ 10 files changed, 965 insertions(+), 3 deletions(-) create mode 100644 example/08-search.php create mode 100644 example/08a-search-results.php create mode 100644 src/AutocompleteHandler.es6 create mode 100644 test/behat/08-search.feature diff --git a/dist/flux.js b/dist/flux.js index 5afa4a7..908d627 100644 --- a/dist/flux.js +++ b/dist/flux.js @@ -350,6 +350,12 @@ var NavigationController = class { this.documentObject = documentObject; } submitForm(form, formData, onDocument, submitter = null) { + return this.requestForm(form, formData, onDocument, submitter, "submitForm"); + } + fetchForm(form, formData, onDocument, submitter = null) { + return this.requestForm(form, formData, onDocument, submitter, null); + } + requestForm(form, formData, onDocument, submitter = null, historyAction = "submitForm") { let method = (form.getAttribute("method") ?? "get").toLowerCase(); let url = form.action; let requestOptions = { @@ -366,7 +372,7 @@ var NavigationController = class { url, requestOptions, { - action: "submitForm", + action: historyAction, errorPrefix: "Form submission error" }, onDocument, @@ -647,6 +653,14 @@ var DIRECTIVE_DEFINITIONS = Object.freeze({ handler: "autoSubmit", description: "Submit the containing form in the background." }, + "autocomplete": { + handler: "autocomplete", + description: "Fetch form results in the background as the user types." + }, + "autocomplete-results": { + handler: "autocompleteResults", + description: "Mark the response element used by autocomplete forms." + }, "link": { handler: "autoLink", description: "Follow the link in the background." @@ -1192,6 +1206,194 @@ var LiveHandler = class _LiveHandler { } }; +// src/AutocompleteHandler.es6 +var AutocompleteHandler = class { + constructor(navigationController, logger = console, debug = false, scheduler = globalThis.setTimeout.bind(globalThis), clearScheduler = globalThis.clearTimeout.bind(globalThis), delay = 200) { + this.navigationController = navigationController; + this.logger = logger; + this.debug = debug; + this.scheduler = scheduler; + this.clearScheduler = clearScheduler; + this.delay = delay; + this.state = /* @__PURE__ */ new WeakMap(); + } + initAutocomplete = (fluxElement) => { + if (!(fluxElement instanceof HTMLFormElement)) { + throw new TypeError('data-flux type "autocomplete" must be applied to a form element.'); + } + if (this.state.has(fluxElement)) { + return; + } + this.state.set(fluxElement, { + timer: null, + minLength: this.getMinLength(fluxElement), + requestId: 0, + resultsElement: null + }); + this.hideSubmitControls(fluxElement); + fluxElement.addEventListener("input", this.onInput); + fluxElement.addEventListener("keydown", this.onKeyDown); + }; + initAutocompleteResults = () => { + }; + onInput = (e) => { + let form = e.currentTarget; + let state = this.state.get(form); + if (!state) { + return; + } + if (state.timer) { + this.clearScheduler(state.timer); + } + state.timer = this.scheduler(() => { + state.timer = null; + this.updateResults(form); + }, this.delay); + }; + onKeyDown = (e) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") { + return; + } + let form = e.currentTarget; + let state = this.state.get(form); + let focusableElements = this.getFocusableElements(form, state?.resultsElement); + if (focusableElements.length === 0) { + return; + } + e.preventDefault(); + this.moveFocus(focusableElements, e.key === "ArrowDown" ? 1 : -1); + }; + updateResults(form) { + let formData = new FormData(form); + let state = this.state.get(form); + if (!state) { + return Promise.resolve(null); + } + if (!this.hasMinimumValue(formData, state.minLength)) { + this.removeResults(form, state); + return Promise.resolve(null); + } + let requestId = ++state.requestId; + return this.navigationController.fetchForm( + form, + formData, + (newDocument) => { + if (state.requestId !== requestId) { + return; + } + this.applyResults(form, state, newDocument); + } + ); + } + getMinLength(form) { + let minLength = Number.parseInt(form.dataset["fluxMinLength"] ?? "", 10); + if (Number.isFinite(minLength) && minLength >= 0) { + return minLength; + } + return 3; + } + hideSubmitControls(form) { + form.querySelectorAll("button, input[type='submit'], input[type='image']").forEach((element) => { + if (element instanceof HTMLButtonElement && element.type !== "submit") { + return; + } + element.hidden = true; + element.dataset["fluxAutocompleteButton"] = ""; + }); + } + hasMinimumValue(formData, minLength) { + for (let value of formData.values()) { + if (typeof value === "string" && value.trim().length >= minLength) { + return true; + } + if (typeof File !== "undefined" && value instanceof File && value.name !== "") { + return true; + } + } + return false; + } + applyResults(form, state, newDocument) { + let newResultsElement = newDocument.querySelector('[data-flux="autocomplete-results"]'); + if (!newResultsElement) { + this.removeResults(form, state); + if (this.debug) { + this.logger.debug("No autocomplete results element found in response", form); + } + return; + } + newResultsElement.dataset["fluxAutocompleteMounted"] = ""; + newResultsElement.addEventListener("keydown", this.onResultsKeyDown); + if (state.resultsElement?.isConnected) { + state.resultsElement.replaceWith(newResultsElement); + } else { + form.after(newResultsElement); + } + state.resultsElement = newResultsElement; + } + onResultsKeyDown = (e) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") { + return; + } + let resultsElement = e.currentTarget; + let form = this.findOwningForm(resultsElement); + if (!form) { + return; + } + let focusableElements = this.getFocusableElements(form, resultsElement); + if (focusableElements.length === 0) { + return; + } + e.preventDefault(); + this.moveFocus(focusableElements, e.key === "ArrowDown" ? 1 : -1); + }; + findOwningForm(resultsElement) { + let previousElement = resultsElement.previousElementSibling; + while (previousElement) { + if (previousElement instanceof HTMLFormElement && this.state.has(previousElement)) { + return previousElement; + } + previousElement = previousElement.previousElementSibling; + } + return null; + } + getFocusableElements(form, resultsElement) { + let selectors = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])' + ].join(","); + let elements = [ + ...form.querySelectorAll(selectors) + ]; + if (resultsElement?.isConnected) { + elements.push(...resultsElement.querySelectorAll(selectors)); + } + return elements.filter((element) => !element.hidden); + } + moveFocus(focusableElements, direction) { + let currentIndex = focusableElements.indexOf(document.activeElement); + let nextIndex = currentIndex + direction; + if (currentIndex === -1) { + nextIndex = direction > 0 ? 0 : focusableElements.length - 1; + } + nextIndex = Math.max(0, Math.min(focusableElements.length - 1, nextIndex)); + focusableElements[nextIndex].focus(); + } + removeResults(form, state) { + if (state.resultsElement?.isConnected) { + state.resultsElement.remove(); + } + state.resultsElement = null; + let adjacentResultsElement = form.nextElementSibling; + if (adjacentResultsElement?.dataset["fluxAutocompleteMounted"] !== void 0) { + adjacentResultsElement.remove(); + } + } +}; + // src/DragOrder/DropTargetResolver.es6 var DropTargetResolver = class { constructor(documentObject = globalThis.document) { @@ -1666,9 +1868,10 @@ var Flux = class _Flux { linkHandler; responseHandler; liveHandler; + autocompleteHandler; dragOrderHandler; logger; - constructor(style = void 0, elementEventMapper = void 0, parser = void 0, navigationController = void 0, updateTargetRegistry = void 0, focusStateManager = void 0, documentUpdater = void 0, directiveRegistry = void 0, domBridge = void 0, formHandler = void 0, linkHandler = void 0, responseHandler = void 0, liveHandler = void 0, logger = void 0, dragOrderHandler = void 0) { + constructor(style = void 0, elementEventMapper = void 0, parser = void 0, navigationController = void 0, updateTargetRegistry = void 0, focusStateManager = void 0, documentUpdater = void 0, directiveRegistry = void 0, domBridge = void 0, formHandler = void 0, linkHandler = void 0, responseHandler = void 0, liveHandler = void 0, logger = void 0, dragOrderHandler = void 0, autocompleteHandler = void 0) { handleWindowPopState(); this.logger = logger ?? console; style = style ?? new Style(); @@ -1724,6 +1927,11 @@ var Flux = class _Flux { () => Date.now(), DomPath ); + this.autocompleteHandler = autocompleteHandler ?? new AutocompleteHandler( + this.navigationController, + this.logger, + _Flux.DEBUG + ); this.dragOrderHandler = dragOrderHandler ?? new Handler( this.formHandler, document, @@ -1741,6 +1949,8 @@ var Flux = class _Flux { liveInner: this.storeLiveInnerUpdateElement, updateAttributes: this.storeAttributesUpdateElement, autoSubmit: this.formHandler.initAutoSubmit, + autocomplete: this.autocompleteHandler.initAutocomplete, + autocompleteResults: this.autocompleteHandler.initAutocompleteResults, autoLink: this.linkHandler.initAutoLink, dragOrder: this.dragOrderHandler.initDragOrder }); diff --git a/example/08-search.php b/example/08-search.php new file mode 100644 index 0000000..c2de843 --- /dev/null +++ b/example/08-search.php @@ -0,0 +1,35 @@ + + + +PHP.GT/Flux example 08 search + + + +
+
+

Search autocomplete

+

The form submits to a separate results page with a normal GET request. Flux previews that page's marked result element while you type.

+
+ +
+
+ + +
+
+
diff --git a/example/08a-search-results.php b/example/08a-search-results.php new file mode 100644 index 0000000..c0b23d6 --- /dev/null +++ b/example/08a-search-results.php @@ -0,0 +1,100 @@ + + + +PHP.GT/Flux example 08a search results + + + +
+
+

Search results

+

Results for

+ +
+ +
+

Matched places

+ +

Enter a search term to find a place.

+ +

No matches for .

+ + + +
+
diff --git a/src/AutocompleteHandler.es6 b/src/AutocompleteHandler.es6 new file mode 100644 index 0000000..40d4995 --- /dev/null +++ b/src/AutocompleteHandler.es6 @@ -0,0 +1,237 @@ +/** + * Enhances plain GET search forms with background result previews. + * Normal form submission is left untouched, so pressing Enter still follows + * the form action using the browser's standard navigation. + */ +export class AutocompleteHandler { + constructor( + navigationController, + logger = console, + debug = false, + scheduler = globalThis.setTimeout.bind(globalThis), + clearScheduler = globalThis.clearTimeout.bind(globalThis), + delay = 200, + ) { + this.navigationController = navigationController; + this.logger = logger; + this.debug = debug; + this.scheduler = scheduler; + this.clearScheduler = clearScheduler; + this.delay = delay; + this.state = new WeakMap(); + } + + initAutocomplete = (fluxElement) => { + if(!(fluxElement instanceof HTMLFormElement)) { + throw new TypeError("data-flux type \"autocomplete\" must be applied to a form element."); + } + + if(this.state.has(fluxElement)) { + return; + } + + this.state.set(fluxElement, { + timer: null, + minLength: this.getMinLength(fluxElement), + requestId: 0, + resultsElement: null, + }); + this.hideSubmitControls(fluxElement); + fluxElement.addEventListener("input", this.onInput); + fluxElement.addEventListener("keydown", this.onKeyDown); + } + + initAutocompleteResults = () => { + } + + onInput = (e) => { + let form = e.currentTarget; + let state = this.state.get(form); + if(!state) { + return; + } + + if(state.timer) { + this.clearScheduler(state.timer); + } + + state.timer = this.scheduler(() => { + state.timer = null; + this.updateResults(form); + }, this.delay); + } + + onKeyDown = (e) => { + if(e.key !== "ArrowDown" && e.key !== "ArrowUp") { + return; + } + + let form = e.currentTarget; + let state = this.state.get(form); + let focusableElements = this.getFocusableElements(form, state?.resultsElement); + if(focusableElements.length === 0) { + return; + } + + e.preventDefault(); + this.moveFocus(focusableElements, e.key === "ArrowDown" ? 1 : -1); + } + + updateResults(form) { + let formData = new FormData(form); + let state = this.state.get(form); + if(!state) { + return Promise.resolve(null); + } + + if(!this.hasMinimumValue(formData, state.minLength)) { + this.removeResults(form, state); + return Promise.resolve(null); + } + + let requestId = ++state.requestId; + return this.navigationController.fetchForm( + form, + formData, + newDocument => { + if(state.requestId !== requestId) { + return; + } + + this.applyResults(form, state, newDocument); + }, + ); + } + + getMinLength(form) { + let minLength = Number.parseInt(form.dataset["fluxMinLength"] ?? "", 10); + if(Number.isFinite(minLength) && minLength >= 0) { + return minLength; + } + + return 3; + } + + hideSubmitControls(form) { + form.querySelectorAll("button, input[type='submit'], input[type='image']").forEach(element => { + if(element instanceof HTMLButtonElement && element.type !== "submit") { + return; + } + + element.hidden = true; + element.dataset["fluxAutocompleteButton"] = ""; + }); + } + + hasMinimumValue(formData, minLength) { + for(let value of formData.values()) { + if(typeof value === "string" && value.trim().length >= minLength) { + return true; + } + + if(typeof File !== "undefined" && value instanceof File && value.name !== "") { + return true; + } + } + + return false; + } + + applyResults(form, state, newDocument) { + let newResultsElement = newDocument.querySelector('[data-flux="autocomplete-results"]'); + if(!newResultsElement) { + this.removeResults(form, state); + if(this.debug) { + this.logger.debug("No autocomplete results element found in response", form); + } + return; + } + + newResultsElement.dataset["fluxAutocompleteMounted"] = ""; + newResultsElement.addEventListener("keydown", this.onResultsKeyDown); + if(state.resultsElement?.isConnected) { + state.resultsElement.replaceWith(newResultsElement); + } + else { + form.after(newResultsElement); + } + + state.resultsElement = newResultsElement; + } + + onResultsKeyDown = (e) => { + if(e.key !== "ArrowDown" && e.key !== "ArrowUp") { + return; + } + + let resultsElement = e.currentTarget; + let form = this.findOwningForm(resultsElement); + if(!form) { + return; + } + + let focusableElements = this.getFocusableElements(form, resultsElement); + if(focusableElements.length === 0) { + return; + } + + e.preventDefault(); + this.moveFocus(focusableElements, e.key === "ArrowDown" ? 1 : -1); + } + + findOwningForm(resultsElement) { + let previousElement = resultsElement.previousElementSibling; + while(previousElement) { + if(previousElement instanceof HTMLFormElement && this.state.has(previousElement)) { + return previousElement; + } + + previousElement = previousElement.previousElementSibling; + } + + return null; + } + + getFocusableElements(form, resultsElement) { + let selectors = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', + ].join(","); + let elements = [ + ...form.querySelectorAll(selectors), + ]; + + if(resultsElement?.isConnected) { + elements.push(...resultsElement.querySelectorAll(selectors)); + } + + return elements.filter(element => !element.hidden); + } + + moveFocus(focusableElements, direction) { + let currentIndex = focusableElements.indexOf(document.activeElement); + let nextIndex = currentIndex + direction; + if(currentIndex === -1) { + nextIndex = direction > 0 ? 0 : focusableElements.length - 1; + } + + nextIndex = Math.max(0, Math.min(focusableElements.length - 1, nextIndex)); + focusableElements[nextIndex].focus(); + } + + removeResults(form, state) { + if(state.resultsElement?.isConnected) { + state.resultsElement.remove(); + } + + state.resultsElement = null; + let adjacentResultsElement = form.nextElementSibling; + if(adjacentResultsElement?.dataset["fluxAutocompleteMounted"] !== undefined) { + adjacentResultsElement.remove(); + } + } +} diff --git a/src/DirectiveRegistry.es6 b/src/DirectiveRegistry.es6 index a35b612..a246c63 100644 --- a/src/DirectiveRegistry.es6 +++ b/src/DirectiveRegistry.es6 @@ -47,6 +47,14 @@ const DIRECTIVE_DEFINITIONS = Object.freeze({ handler: "autoSubmit", description: "Submit the containing form in the background.", }, + "autocomplete": { + handler: "autocomplete", + description: "Fetch form results in the background as the user types.", + }, + "autocomplete-results": { + handler: "autocompleteResults", + description: "Mark the response element used by autocomplete forms.", + }, "link": { handler: "autoLink", description: "Follow the link in the background.", diff --git a/src/Flux.es6 b/src/Flux.es6 index d4fde91..291868c 100644 --- a/src/Flux.es6 +++ b/src/Flux.es6 @@ -11,6 +11,7 @@ import {FormHandler} from "./FormHandler.es6"; import {LinkHandler} from "./LinkHandler.es6"; import {ResponseHandler} from "./ResponseHandler.es6"; import {LiveHandler} from "./LiveHandler.es6"; +import {AutocompleteHandler} from "./AutocompleteHandler.es6"; import {Handler as DragOrderHandler} from "./DragOrder/Handler.es6"; import {RuntimeConfig} from "./RuntimeConfig.es6"; @@ -40,6 +41,7 @@ export class Flux { linkHandler; responseHandler; liveHandler; + autocompleteHandler; dragOrderHandler; logger; @@ -59,6 +61,7 @@ export class Flux { liveHandler = undefined, logger = undefined, dragOrderHandler = undefined, + autocompleteHandler = undefined, ) { handleWindowPopState(); this.logger = logger ?? console; @@ -115,6 +118,11 @@ export class Flux { () => Date.now(), DomPath, ); + this.autocompleteHandler = autocompleteHandler ?? new AutocompleteHandler( + this.navigationController, + this.logger, + Flux.DEBUG, + ); this.dragOrderHandler = dragOrderHandler ?? new DragOrderHandler( this.formHandler, document, @@ -132,6 +140,8 @@ export class Flux { liveInner: this.storeLiveInnerUpdateElement, updateAttributes: this.storeAttributesUpdateElement, autoSubmit: this.formHandler.initAutoSubmit, + autocomplete: this.autocompleteHandler.initAutocomplete, + autocompleteResults: this.autocompleteHandler.initAutocompleteResults, autoLink: this.linkHandler.initAutoLink, dragOrder: this.dragOrderHandler.initDragOrder, }); diff --git a/src/NavigationController.es6 b/src/NavigationController.es6 index 68347c8..e81d7aa 100644 --- a/src/NavigationController.es6 +++ b/src/NavigationController.es6 @@ -19,6 +19,14 @@ export class NavigationController { } submitForm(form, formData, onDocument, submitter = null) { + return this.requestForm(form, formData, onDocument, submitter, "submitForm"); + } + + fetchForm(form, formData, onDocument, submitter = null) { + return this.requestForm(form, formData, onDocument, submitter, null); + } + + requestForm(form, formData, onDocument, submitter = null, historyAction = "submitForm") { let method = (form.getAttribute("method") ?? "get").toLowerCase(); let url = form.action; let requestOptions = { @@ -38,7 +46,7 @@ export class NavigationController { url, requestOptions, { - action: "submitForm", + action: historyAction, errorPrefix: "Form submission error", }, onDocument, diff --git a/test/Flux.test.js b/test/Flux.test.js index 7e1e494..7a72c5a 100644 --- a/test/Flux.test.js +++ b/test/Flux.test.js @@ -12,6 +12,7 @@ import {FormHandler} from "../src/FormHandler.es6"; import {LinkHandler} from "../src/LinkHandler.es6"; import {ResponseHandler} from "../src/ResponseHandler.es6"; import {LiveHandler} from "../src/LiveHandler.es6"; +import {AutocompleteHandler} from "../src/AutocompleteHandler.es6"; import {Handler as DragOrderHandler} from "../src/DragOrder/Handler.es6"; import {Preview} from "../src/DragOrder/Preview.es6"; @@ -649,6 +650,38 @@ describe("NavigationController", () => { expect(pushState).not.toHaveBeenCalled(); expect(callback).toHaveBeenCalledWith(expect.any(Document)); }); + + it("fetches a form document without pushing history state", async () => { + document.body.innerHTML = ` +
+ +
+ `; + + let form = document.querySelector("form"); + let callback = vi.fn(); + let pushState = vi.fn(); + let fetcher = vi.fn().mockResolvedValue({ + ok: true, + url: "https://example.com/search?query=London", + text: vi.fn().mockResolvedValue("
Results
"), + }); + let navigationController = new NavigationController( + new DOMParser(), + fetcher, + {pushState}, + {error: vi.fn()}, + ); + + await navigationController.fetchForm(form, new FormData(form), callback); + + expect(fetcher).toHaveBeenCalledWith("http://localhost:3000/search?query=London", { + method: "get", + credentials: "same-origin", + }); + expect(pushState).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(expect.any(Document)); + }); }); describe("DocumentUpdater", () => { @@ -1148,6 +1181,8 @@ describe("DirectiveRegistry", () => { "live-inner": expect.objectContaining({handler: "liveInner"}), "update-attributes": expect.objectContaining({handler: "updateAttributes"}), "submit": expect.objectContaining({handler: "autoSubmit"}), + "autocomplete": expect.objectContaining({handler: "autocomplete"}), + "autocomplete-results": expect.objectContaining({handler: "autocompleteResults"}), "link": expect.objectContaining({handler: "autoLink"}), "drag-order": expect.objectContaining({handler: "dragOrder"}), }); @@ -1478,6 +1513,222 @@ describe("FormHandler", () => { }); }); +describe("AutocompleteHandler", () => { + it("fetches and mounts marked results after input changes", async () => { + vi.useFakeTimers(); + document.body.innerHTML = ` +
+ +
+ `; + + let parser = new DOMParser(); + let navigationController = { + fetchForm: vi.fn((form, formData, onDocument) => { + let newDocument = parser.parseFromString(` + + +
+

London result

+
+ + + `, "text/html"); + onDocument(newDocument); + return Promise.resolve(newDocument); + }), + }; + let handler = new AutocompleteHandler(navigationController); + let form = document.querySelector("form"); + let input = document.querySelector("input"); + + handler.initAutocomplete(form); + input.value = "London"; + input.dispatchEvent(new Event("input", {bubbles: true})); + await vi.advanceTimersByTimeAsync(200); + + expect(navigationController.fetchForm).toHaveBeenCalledTimes(1); + expect(navigationController.fetchForm.mock.calls[0][1].get("query")).toBe("London"); + expect(form.nextElementSibling.matches('[data-flux="autocomplete-results"]')).toBe(true); + expect(form.nextElementSibling.textContent).toContain("London result"); + + vi.useRealTimers(); + }); + + it("removes mounted results when the form value is below the minimum length", async () => { + document.body.innerHTML = ` +
+ +
+ `; + + let parser = new DOMParser(); + let navigationController = { + fetchForm: vi.fn((form, formData, onDocument) => { + let newDocument = parser.parseFromString(` + + +
+

London result

+
+ + + `, "text/html"); + onDocument(newDocument); + return Promise.resolve(newDocument); + }), + }; + let handler = new AutocompleteHandler(navigationController); + let form = document.querySelector("form"); + let input = document.querySelector("input"); + + handler.initAutocomplete(form); + await handler.updateResults(form); + expect(form.nextElementSibling).not.toBeNull(); + + input.value = "Lo"; + await handler.updateResults(form); + + expect(form.nextElementSibling).toBeNull(); + expect(navigationController.fetchForm).toHaveBeenCalledTimes(1); + }); + + it("hides submit controls when autocomplete is initialised", () => { + document.body.innerHTML = ` +
+ + + + +
+ `; + + let handler = new AutocompleteHandler({fetchForm: vi.fn()}); + let form = document.querySelector("form"); + let buttons = document.querySelectorAll("button"); + let submitInput = document.querySelector("input[type='submit']"); + + handler.initAutocomplete(form); + + expect(buttons[0].hidden).toBe(true); + expect(buttons[0].dataset["fluxAutocompleteButton"]).toBe(""); + expect(buttons[1].hidden).toBe(false); + expect(submitInput.hidden).toBe(true); + }); + + it("ignores stale autocomplete responses", async () => { + document.body.innerHTML = ` +
+ +
+ `; + + let parser = new DOMParser(); + let callbacks = []; + let navigationController = { + fetchForm: vi.fn((form, formData, onDocument) => { + callbacks.push({ + query: formData.get("query"), + onDocument, + }); + return Promise.resolve(null); + }), + }; + let handler = new AutocompleteHandler(navigationController); + let form = document.querySelector("form"); + let input = document.querySelector("input"); + + handler.initAutocomplete(form); + handler.updateResults(form); + input.value = "London"; + handler.updateResults(form); + + callbacks[1].onDocument(parser.parseFromString(` +
London
+ `, "text/html")); + callbacks[0].onDocument(parser.parseFromString(` +
Lon stale
+ `, "text/html")); + + expect(callbacks.map(callback => callback.query)).toEqual(["Lon", "London"]); + expect(form.nextElementSibling.textContent).toBe("London"); + }); + + it("moves through form controls and mounted result links with arrow keys", async () => { + document.body.innerHTML = ` +
+ + +
+ `; + + let parser = new DOMParser(); + let navigationController = { + fetchForm: vi.fn((form, formData, onDocument) => { + let newDocument = parser.parseFromString(` + + +
+ One + Two +
+ + + `, "text/html"); + onDocument(newDocument); + return Promise.resolve(newDocument); + }), + }; + let handler = new AutocompleteHandler(navigationController); + let form = document.querySelector("form"); + let input = document.querySelector("input"); + let button = document.querySelector("button"); + + handler.initAutocomplete(form); + await handler.updateResults(form); + let links = form.nextElementSibling.querySelectorAll("a"); + input.focus(); + + document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + })); + expect(document.activeElement).toBe(links[0]); + expect(button.hidden).toBe(true); + + document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowDown", + bubbles: true, + cancelable: true, + })); + expect(document.activeElement).toBe(links[1]); + + document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true, + })); + expect(document.activeElement).toBe(links[0]); + + document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { + key: "ArrowUp", + bubbles: true, + cancelable: true, + })); + expect(document.activeElement).toBe(input); + }); + + it("requires autocomplete to be applied to a form", () => { + let handler = new AutocompleteHandler({fetchForm: vi.fn()}); + let element = document.createElement("div"); + + expect(() => handler.initAutocomplete(element)).toThrow( + 'data-flux type "autocomplete" must be applied to a form element.', + ); + }); +}); + describe("DragOrderHandler", () => { it("hides the order controls and adds a draggable handle to the form", () => { document.body.innerHTML = ` diff --git a/test/behat/08-search.feature b/test/behat/08-search.feature new file mode 100644 index 0000000..abc1c3a --- /dev/null +++ b/test/behat/08-search.feature @@ -0,0 +1,15 @@ +@javascript +Feature: Search autocomplete example + Scenario: Search results preview without taking over normal form submission + Given I am on "/example/08-search.php" + Then Flux should be ready + When I change the element "input[name='query']" to "London" + Then I wait until the element "[data-flux='autocomplete-results']" contains "10 Downing Street" + And the current URL path should be "/example/08-search.php" + When I press the ArrowDown key in the element "input[name='query']" + Then the active element should contain "10 Downing Street" + When I press the ArrowUp key in the active element + Then the element "input[name='query']" should be focussed + When I press Enter in the element "input[name='query']" + Then I wait until the element "[data-flux='autocomplete-results']" contains "Waterloo Station" + And the current URL path should be "/example/08a-search-results.php" diff --git a/test/behat/bootstrap/FeatureContext.php b/test/behat/bootstrap/FeatureContext.php index 936d0db..487f1c8 100644 --- a/test/behat/bootstrap/FeatureContext.php +++ b/test/behat/bootstrap/FeatureContext.php @@ -88,6 +88,55 @@ public function iPressEnterInTheElement(string $selector):void { $this->getSession()->executeScript($script); } + /** + * @When I press the :keyName key in the element :selector + */ + public function iPressKeyInTheElement(string $keyName, string $selector):void { + $escapedSelector = json_encode($selector, JSON_THROW_ON_ERROR); + $escapedKeyName = json_encode($keyName, JSON_THROW_ON_ERROR); + $script = << { + const element = document.querySelector($escapedSelector); + if(!element) { + throw new Error("Could not find element: " + $escapedSelector); + } + + element.focus(); + element.dispatchEvent(new KeyboardEvent("keydown", { + key: $escapedKeyName, + code: $escapedKeyName, + bubbles: true, + cancelable: true, + })); + })() + JS; + + $this->getSession()->executeScript($script); + } + + /** + * @When I press the :keyName key in the active element + */ + public function iPressKeyInTheActiveElement(string $keyName):void { + $escapedKeyName = json_encode($keyName, JSON_THROW_ON_ERROR); + $script = << { + if(!document.activeElement) { + throw new Error("There is no active element."); + } + + document.activeElement.dispatchEvent(new KeyboardEvent("keydown", { + key: $escapedKeyName, + code: $escapedKeyName, + bubbles: true, + cancelable: true, + })); + })() + JS; + + $this->getSession()->executeScript($script); + } + /** * @Then /^the element "([^"]+)" should have value "(.*)"$/ */ @@ -103,6 +152,30 @@ public function theElementShouldHaveValue(string $selector, string $value):void } } + /** + * @Then the active element should contain :text + */ + public function theActiveElementShouldContain(string $text):void { + $escapedText = json_encode($text, JSON_THROW_ON_ERROR); + $condition = << document.activeElement && document.activeElement.textContent.includes($escapedText))() + JS; + + $this->waitForCondition($condition, sprintf('Timed out waiting for the active element to contain "%s".', $text)); + } + + /** + * @Then the element :selector should be focussed + */ + public function theElementShouldBeFocussed(string $selector):void { + $escapedSelector = json_encode($selector, JSON_THROW_ON_ERROR); + $condition = << document.activeElement === document.querySelector($escapedSelector))() + JS; + + $this->waitForCondition($condition, sprintf('Timed out waiting for "%s" to be focussed.', $selector)); + } + /** * @When I drag the item with id :id to position :position in :selector */ @@ -356,6 +429,21 @@ public function fluxShouldBeReady():void { ); } + /** + * @Then the current URL path should be :expectedPath + */ + public function theCurrentUrlPathShouldBe(string $expectedPath):void { + $currentUrl = $this->getSession()->getCurrentUrl(); + $actualPath = parse_url($currentUrl, PHP_URL_PATH); + + if($actualPath !== $expectedPath) { + throw new ExpectationException( + sprintf('Expected current URL path to be "%s", got "%s".', $expectedPath, $actualPath), + $this->getSession(), + ); + } + } + /** * @When /^I remember the time from the page$/ */