From 6bdb91ab963db5c6284ca9d13ab3bc065bc1e066 Mon Sep 17 00:00:00 2001 From: James Little <3058200+jameslittle230@users.noreply.github.com> Date: Sun, 14 Jun 2020 21:19:28 -0700 Subject: [PATCH] Better keyboard interaction (#44) * Use keyboard and mouse events to control highlight * Escape to hide results * Make the test page a lil better * Bump version number --- Cargo.lock | 2 +- Cargo.toml | 2 +- js/dom.js | 10 ++++ js/entity.ts | 117 ++++++++++++++++++++++++++++++++++------- js/index.js | 19 ++++--- package.json | 2 +- test/static/basic.css | 1 - test/static/index.html | 9 +++- 8 files changed, 130 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1d83acfd..cba38fbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,7 +175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] name = "stork-search" -version = "0.7.0" +version = "0.7.1" dependencies = [ "bincode 1.2.1 (registry+https://github.com/rust-lang/crates.io-index)", "console_error_panic_hook 0.1.6 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 81f62c82..2bd031b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stork-search" -version = "0.7.0" +version = "0.7.1" authors = ["James Little "] edition = "2018" documentation = "https://stork-search.net/docs" diff --git a/js/dom.js b/js/dom.js index c360a298..8d073202 100644 --- a/js/dom.js +++ b/js/dom.js @@ -24,3 +24,13 @@ export function setText(elem, text) { elem.appendChild(textNode); } } + +export function existsBeyondContainerBounds(elem, container) { + var elemBoundingBox = elem.getBoundingClientRect(); + var containerBoundingBox = container.getBoundingClientRect(); + + return ( + elemBoundingBox.bottom > containerBoundingBox.bottom || + elemBoundingBox.top < containerBoundingBox.top + ); +} diff --git a/js/entity.ts b/js/entity.ts index d38f5bb9..82e88960 100644 --- a/js/entity.ts +++ b/js/entity.ts @@ -1,6 +1,12 @@ import { Configuration, calculateOverriddenConfig } from "./config"; import { assert, htmlToElement } from "./util"; -import { create, add, clear, setText } from "./dom"; +import { + create, + add, + clear, + setText, + existsBeyondContainerBounds +} from "./dom"; import { generateListItem } from "./pencil"; interface Result { @@ -16,6 +22,14 @@ interface ElementMap { message?: Element; } +interface RenderOptions { + shouldScrollTo: boolean; +} + +const defaultRenderOptions: RenderOptions = { + shouldScrollTo: false +}; + export class Entity { name: string; url: string; @@ -26,6 +40,8 @@ export class Entity { progress = 0; hitCount = 0; query = ""; + resultsVisible = false; + hoverSelectEnabled = true; // render options scrollAnchorPoint: "start" | "end" = "end"; @@ -71,18 +87,80 @@ export class Entity { return null; } - changeHighlightedResult(delta: number): number { + setResultsVisible(val: boolean): void { + const prev = this.resultsVisible; + this.resultsVisible = val; + + if (val !== prev) { + this.render(); + } + } + + changeHighlightedResult(options: { + by: number | null; + to: number | null; + }): number { const previousValue = this.highlightedResult; - const intendedIdx = this.highlightedResult + delta; + const intendedIdx = (() => { + if (typeof options.to === "number") { + return options.to; + } else if (typeof options.by === "number") { + return this.highlightedResult + options.by; + } else { + return 0; + } + })(); + + options.to !== null + ? options.to + : this.highlightedResult + (options.by || 0); + const resolvedIdx = Math.max( 0, Math.min(this.results.length - 1, intendedIdx) ); - this.highlightedResult = resolvedIdx; + this.highlightedResult = resolvedIdx; this.scrollAnchorPoint = previousValue < resolvedIdx ? "end" : "start"; + let targetForScrollTo = null; + + for (let i = 0; i < this.results.length; i++) { + const element = this.elements.list?.children[i]; + if (!element) { + continue; + } + + const highlightedClassName = "selected"; + + if (i == resolvedIdx) { + element.classList.add(highlightedClassName); + targetForScrollTo = element; + } else { + element.classList.remove(highlightedClassName); + } + } + + // using options.by as a proxy for keyboard selection + if (typeof options.by === "number") { + this.hoverSelectEnabled = false; + if (targetForScrollTo) { + if ( + existsBeyondContainerBounds( + targetForScrollTo as HTMLElement, + this.elements.list + ) + ) { + (targetForScrollTo as HTMLElement).scrollIntoView({ + behavior: "smooth", + block: this.scrollAnchorPoint, + inline: "nearest" + }); + } + } + } + return resolvedIdx; } @@ -121,7 +199,7 @@ export class Entity { setText(this.elements.message, message); // Render results - if (this.results?.length > 0) { + if (this.results?.length > 0 && this.resultsVisible) { // Create list if it doesn't exist if (!this.elements.list) { this.elements.list = create("ul", { @@ -131,9 +209,11 @@ export class Entity { } clear(this.elements.list); + this.elements.list?.addEventListener("mousemove", event => { + this.hoverSelectEnabled = true; + }); // Render each result - let targetForScrollTo: ChildNode | null = null; for (let i = 0; i < this.results.length; i++) { const result = this.results[i]; const generateOptions = { @@ -146,22 +226,19 @@ export class Entity { generateListItem(generateOptions) ); - if (this.elements.list && elementToInsert) { - const insertedElement = this.elements.list.appendChild( + if (elementToInsert) { + const insertedElement = this.elements.list?.appendChild( elementToInsert ); - if (i === this.highlightedResult) { - targetForScrollTo = insertedElement; - } - } - } - if (targetForScrollTo) { - (targetForScrollTo as HTMLElement).scrollIntoView({ - behavior: "smooth", - block: this.scrollAnchorPoint, - inline: "nearest" - }); + insertedElement?.addEventListener("mousemove", event => { + if (this.hoverSelectEnabled) { + if (i !== this.highlightedResult) { + this.changeHighlightedResult({ by: null, to: i }); + } + } + }); + } } } else if (this.elements.list) { this.elements.output.removeChild(this.elements.list); @@ -169,7 +246,7 @@ export class Entity { } // Remove output's contents if there's no query - if (!this.query || this.query.length === 0) { + if (!this.query || this.query.length === 0 || !this.resultsVisible) { delete this.elements.message; delete this.elements.list; clear(this.elements.output); diff --git a/js/index.js b/js/index.js index 55610159..ac084784 100644 --- a/js/index.js +++ b/js/index.js @@ -54,27 +54,34 @@ function handleInputEvent(event) { * (keypress event doesn't work here) */ function handleKeyDownEvent(event) { - console.log(event); const LEFT = 37; const UP = 38; const RIGHT = 39; const DOWN = 40; const RETURN = 13; + const SPACE = 32; + const ESC = 27; const name = event.target.getAttribute("data-stork"); const entity = entities[name]; + if (![LEFT, UP, RIGHT, DOWN, RETURN, ESC].includes(event.keyCode)) { + console.log(entity.resultsVisible); + entity.setResultsVisible(true); + return; + } + const resultNodeArray = Array.from( entity.elements.list ? entity.elements.list.childNodes : [] ).filter(n => n.className == "stork-result"); switch (event.keyCode) { case DOWN: - entity.changeHighlightedResult(+1); + entity.changeHighlightedResult({ by: +1 }); break; case UP: - entity.changeHighlightedResult(-1); + entity.changeHighlightedResult({ by: -1 }); break; case RETURN: @@ -82,14 +89,13 @@ function handleKeyDownEvent(event) { .filter(n => n.href)[0] // get the `a` element .click(); + case ESC: + entity.setResultsVisible(false); break; default: return; } - - console.log(entities[name]); - entities[name].render(); } function performSearch(name) { @@ -116,6 +122,7 @@ function performSearch(name) { entities[name].results = results.results; entities[name].hitCount = results.total_hit_count; + entities[name].highlightedResult = 0; // Mutate the result URL, like we do when there's a url prefix or suffix const urlPrefix = results.url_prefix || ""; diff --git a/package.json b/package.json index 52f51e7d..3537bb31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stork-search", - "version": "0.7.0", + "version": "0.7.1", "description": "Impossibly fast web search, made for static sites.", "main": "index.js", "repository": { diff --git a/test/static/basic.css b/test/static/basic.css index 910989d6..929da858 100644 --- a/test/static/basic.css +++ b/test/static/basic.css @@ -71,7 +71,6 @@ border-bottom: 1px solid hsla(0, 0%, 90%, 1); } -.stork-result:hover, .stork-result.selected { background: rgb(151, 226, 245); } diff --git a/test/static/index.html b/test/static/index.html index 3a23e45e..92ad1434 100644 --- a/test/static/index.html +++ b/test/static/index.html @@ -8,12 +8,17 @@ .search-wrap { max-width: 1200px; display: flex; - justify-content: space-between; + flex-direction: column; } - + .stork-wrapper { max-width: 550px; width: 100%; + margin-bottom: 3rem; + } + + .stork-output-visible { + z-index: 200; } .subtitle {