From f150a6c9b57b05e4ffd076facee2a58c7142f89e Mon Sep 17 00:00:00 2001 From: squidfunk Date: Fri, 14 Feb 2020 11:33:59 +0100 Subject: [PATCH] Refactored search keyboard handlers --- .../javascripts/components2/search/index.ts | 6 +- .../components2/search/reset/index.ts | 4 +- .../components2/search/result/index.ts | 68 ++++++++++- src/assets/javascripts/index.ts | 115 +++--------------- .../observables/agent/element/focus/index.ts | 19 +++ .../observables/agent/keyboard/index.ts | 4 +- 6 files changed, 108 insertions(+), 108 deletions(-) diff --git a/src/assets/javascripts/components2/search/index.ts b/src/assets/javascripts/components2/search/index.ts index 5b9ef47d1..1d6e41144 100644 --- a/src/assets/javascripts/components2/search/index.ts +++ b/src/assets/javascripts/components2/search/index.ts @@ -25,6 +25,7 @@ import { map, shareReplay, switchMap } from "rxjs/operators" import { SearchResult } from "modules" import { + Key, SearchQuery, Viewport, WorkerHandler, @@ -57,6 +58,7 @@ export interface Search { */ interface MountOptions { viewport$: Observable /* Viewport observable */ + keyboard$: Observable /* Keyboard observable */ } /* ---------------------------------------------------------------------------- @@ -72,7 +74,7 @@ interface MountOptions { * @return Search observable */ export function mountSearch( - handler: WorkerHandler, { viewport$ }: MountOptions + handler: WorkerHandler, { viewport$, keyboard$ }: MountOptions ): OperatorFunction { return pipe( switchMap(() => { @@ -93,7 +95,7 @@ export function mountSearch( /* Mount search result */ const result$ = useComponent("search-result") .pipe( - mountSearchResult(handler, { viewport$, query$ }) + mountSearchResult(handler, { query$, viewport$, keyboard$ }) ) /* Combine into a single hot observable */ diff --git a/src/assets/javascripts/components2/search/reset/index.ts b/src/assets/javascripts/components2/search/reset/index.ts index 9f027b6cf..edcd6e42d 100644 --- a/src/assets/javascripts/components2/search/reset/index.ts +++ b/src/assets/javascripts/components2/search/reset/index.ts @@ -29,7 +29,7 @@ import { tap } from "rxjs/operators" -import { watchSearchReset } from "observables" +import { setElementFocus, watchSearchReset } from "observables" import { useComponent } from "../../_" @@ -47,7 +47,7 @@ export function mountSearchReset(): OperatorFunction { return pipe( switchMap(watchSearchReset), switchMapTo(query$), - tap(el => el.focus()), + tap(setElementFocus), mapTo(undefined), startWith(undefined) ) diff --git a/src/assets/javascripts/components2/search/result/index.ts b/src/assets/javascripts/components2/search/result/index.ts index b64251694..cda6473bd 100644 --- a/src/assets/javascripts/components2/search/result/index.ts +++ b/src/assets/javascripts/components2/search/result/index.ts @@ -28,22 +28,33 @@ import { map, pluck, shareReplay, - switchMap + switchMap, + withLatestFrom } from "rxjs/operators" +import { setToggle } from "actions" import { SearchResult } from "modules" import { + Key, SearchQuery, Viewport, WorkerHandler, + getActiveElement, + getElements, paintSearchResult, - watchElementOffset + setElementFocus, + useToggle, + watchElementOffset, + watchToggle } from "observables" +import { takeIf } from "utilities" import { SearchMessage, isSearchResultMessage } from "workers" +import { useComponent } from "../../_" + /* ---------------------------------------------------------------------------- * Helper types * ------------------------------------------------------------------------- */ @@ -54,6 +65,7 @@ import { interface MountOptions { query$: Observable /* Search query observable */ viewport$: Observable /* Viewport observable */ + keyboard$: Observable /* Keyboard observable */ } /* ---------------------------------------------------------------------------- @@ -70,8 +82,9 @@ interface MountOptions { */ export function mountSearchResult( { rx$ }: WorkerHandler, - { query$, viewport$ }: MountOptions + { query$, viewport$, keyboard$ }: MountOptions ): OperatorFunction { + const toggle$ = useToggle("search") return pipe( switchMap(el => { const container = el.parentElement! @@ -86,6 +99,55 @@ export function mountSearchResult( filter(identity) ) + /* Setup keyboard navigation in search mode */ + keyboard$ + .pipe( + takeIf(toggle$.pipe(switchMap(watchToggle))), + withLatestFrom(toggle$, useComponent("search-query")) + ) + .subscribe(([key, toggle, query]) => { + const active = getActiveElement() + switch (key.type) { + + /* Enter: prevent form submission */ + case "Enter": + if (active === query) + key.claim() + break + + /* Escape or Tab: close search */ + case "Escape": + case "Tab": + setToggle(toggle, false) + setElementFocus(query, false) + break + + /* Vertical arrows: select previous or next search result */ + case "ArrowUp": + case "ArrowDown": + if (typeof active === "undefined") { + setElementFocus(query) + } else { + const els = [query, ...getElements("[href]", el)] + const i = Math.max(0, ( + Math.max(0, els.indexOf(active)) + els.length + ( + key.type === "ArrowUp" ? -1 : +1 + ) + ) % els.length) + setElementFocus(els[i]) + } + + /* Prevent scrolling of page */ + key.claim() + break + + /* All other keys: hand to search query */ + default: + if (query !== getActiveElement()) + setElementFocus(query) + } + }) + /* Paint search results */ return rx$ .pipe( diff --git a/src/assets/javascripts/index.ts b/src/assets/javascripts/index.ts index 43e327253..a44248b91 100644 --- a/src/assets/javascripts/index.ts +++ b/src/assets/javascripts/index.ts @@ -27,7 +27,7 @@ import "../stylesheets/app.scss" import "../stylesheets/app-palette.scss" import * as Clipboard from "clipboard" -import { identity, not, values } from "ramda" +import { identity, values } from "ramda" import { EMPTY, merge, @@ -39,9 +39,7 @@ import { filter, map, switchMap, - tap, - withLatestFrom, - switchMapTo + tap } from "rxjs/operators" import { @@ -59,14 +57,10 @@ import { watchViewport, watchKeyboard, watchToggleMap, - useToggle, - getActiveElement, - mayReceiveKeyboardEvents, - watchMain + useToggle } from "./observables" import { setupSearchWorker } from "./workers" import { renderSource } from "templates" -import { takeIf } from "utilities" import { renderClipboard } from "templates/clipboard" import { fetchGitHubStats } from "modules/source/github" import { renderTable } from "templates/table" @@ -247,7 +241,7 @@ export function initialize(config: unknown) { const search$ = useComponent("search") .pipe( - mountSearch(sw, { viewport$ }), + mountSearch(sw, { viewport$, keyboard$ }), ) /* ----------------------------------------------------------------------- */ @@ -274,95 +268,20 @@ export function initialize(config: unknown) { /* ----------------------------------------------------------------------- */ - function openSearchOnHotKey() { - const toggle$ = useToggle("search") - const search$ = toggle$ - .pipe( - switchMap(watchToggle) - ) - - const query$ = useComponent("search-query") - - search$ - .pipe( - filter(not), - switchMapTo(keyboard$), - filter(key => ["KeyS", "KeyF"].includes(key.code)), - switchMapTo(toggle$) - ) - .subscribe(toggle => { - const el = getActiveElement() - if (!(el && mayReceiveKeyboardEvents(el))) - setToggle(toggle, true) - }) - - search$ - .pipe( - filter(identity), - switchMapTo(keyboard$), - filter(key => ["Escape", "Tab"].includes(key.code)), - switchMapTo(toggle$), - withLatestFrom(query$) - ) - .subscribe(([toggle, el]) => { - setToggle(toggle, false) - el.blur() - }) - } // TODO: handle ALL cases in one switch case statement! + // search$ + // .pipe( + // filter(not), + // switchMapTo(keyboard$), + // filter(key => ["s", "f"].includes(key.type)), + // switchMapTo(toggle$) + // ) + // .subscribe(toggle => { + // const el = getActiveElement() + // if (!(el && mayReceiveKeyboardEvents(el))) + // setToggle(toggle, true) + // }) const search = getElement("[data-md-toggle=search]")! - const searchActive$ = useToggle("search").pipe( - switchMap(el => watchToggle(el)), - delay(400) - ) - - - openSearchOnHotKey() - - - // note that all links have tabindex=-1 - keyboard$ - .pipe( - takeIf(searchActive$), - - /* Abort if meta key (macOS) or ctrl key (Windows) is pressed */ - tap(key => { - console.log("jo", key) - if (key.code === "Enter") { - if (document.activeElement === getElement("[data-md-component=search-query]")) { - key.claim() - // intercept hash change after search closed - } else { - setToggle(search, false) - } - } - - if (key.code === "ArrowUp" || key.code === "ArrowDown") { - const active = getElements("[data-md-component=search-query], [data-md-component=search-result] [href]") - const i = Math.max(0, active.findIndex(el => el === document.activeElement)) - const x = Math.max(0, (i + active.length + (key.code === "ArrowUp" ? -1 : +1)) % active.length) - active[x].focus() - - // pass keyboard to search result!? - - /* Prevent scrolling of page */ - key.claim() - - // } else if (key.code === "Escape" || key.code === "Tab") { - // setToggle(search, false) - // getElement("[data-md-component=search-query]")!.blur() - - } else { - if (search.checked && document.activeElement !== getElement("[data-md-component=search-query]")) { - getElement("[data-md-component=search-query]")!.focus() - } - } - }) - ) - .subscribe() - - // TODO: close search on hashchange - // anchor jump -> always close drawer + search /* ----------------------------------------------------------------------- */ @@ -470,8 +389,6 @@ export function initialize(config: unknown) { /* ----------------------------------------------------------------------- */ - /* ----------------------------------------------------------------------- */ - const state = { search$, main$, diff --git a/src/assets/javascripts/observables/agent/element/focus/index.ts b/src/assets/javascripts/observables/agent/element/focus/index.ts index 7fb56264e..21a1df3e1 100644 --- a/src/assets/javascripts/observables/agent/element/focus/index.ts +++ b/src/assets/javascripts/observables/agent/element/focus/index.ts @@ -29,6 +29,25 @@ import { getActiveElement } from "../_" * Functions * ------------------------------------------------------------------------- */ +/** + * Set element focus + * + * @param el - Element + * @param value - Whether the element should be focused + * + * @return Element offset + */ +export function setElementFocus( + el: HTMLElement, value: boolean = true +): void { + if (value) + el.focus() + else + el.blur() +} + +/* ------------------------------------------------------------------------- */ + /** * Watch element focus * diff --git a/src/assets/javascripts/observables/agent/keyboard/index.ts b/src/assets/javascripts/observables/agent/keyboard/index.ts index 1ae6f5028..34297787e 100644 --- a/src/assets/javascripts/observables/agent/keyboard/index.ts +++ b/src/assets/javascripts/observables/agent/keyboard/index.ts @@ -31,7 +31,7 @@ import { filter, map, share } from "rxjs/operators" * Key */ export interface Key { - code: string /* Key code */ + type: string /* Key type */ claim(): void /* Key claim */ } @@ -82,7 +82,7 @@ export function watchKeyboard(): Observable { .pipe( filter(ev => !(ev.shiftKey || ev.metaKey || ev.ctrlKey)), map(ev => ({ - code: ev.code, + type: ev.key, claim() { ev.preventDefault() ev.stopPropagation()