+
+
+
+ `, "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$/
*/