Added back keyboard handlers

This commit is contained in:
squidfunk
2021-02-12 16:41:33 +01:00
parent 44751785a0
commit e7850ca184
13 changed files with 177 additions and 37 deletions

View File

@@ -23,20 +23,33 @@
import { Observable, fromEvent } from "rxjs"
import { filter, map, share } from "rxjs/operators"
import { getActiveElement } from "../element"
import { getToggle } from "../toggle"
/* ----------------------------------------------------------------------------
* Types
* ------------------------------------------------------------------------- */
/**
* Key
* Keyboard mode
*/
export interface Key {
export type KeyboardMode =
| "global" /* Global */
| "search" /* Search is open */
/* ------------------------------------------------------------------------- */
/**
* Keyboard
*/
export interface Keyboard {
mode: KeyboardMode /* Keyboard mode */
type: string /* Key type */
claim(): void /* Key claim */
}
/* ----------------------------------------------------------------------------
* Functions
* Helper functions
* ------------------------------------------------------------------------- */
/**
@@ -46,7 +59,7 @@ export interface Key {
*
* @returns Test result
*/
export function isSusceptibleToKeyboard(el: HTMLElement): boolean {
function isSusceptibleToKeyboard(el: HTMLElement): boolean {
switch (el.tagName) {
/* Form elements */
@@ -61,24 +74,35 @@ export function isSusceptibleToKeyboard(el: HTMLElement): boolean {
}
}
/* ------------------------------------------------------------------------- */
/* ----------------------------------------------------------------------------
* Functions
* ------------------------------------------------------------------------- */
/**
* Watch keyboard
*
* @returns Keyboard observable
*/
export function watchKeyboard(): Observable<Key> {
export function watchKeyboard(): Observable<Keyboard> {
return fromEvent<KeyboardEvent>(window, "keydown")
.pipe(
filter(ev => !(ev.metaKey || ev.ctrlKey)),
map(ev => ({
mode: getToggle("search") ? "search" : "global",
type: ev.key,
claim() {
ev.preventDefault()
ev.stopPropagation()
}
})),
} as Keyboard)),
filter(({ mode }) => {
if (mode === "global") {
const active = getActiveElement()
if (typeof active !== "undefined")
return !isSusceptibleToKeyboard(active)
}
return true
}),
share()
)
}

View File

@@ -25,8 +25,14 @@ import { filter, sample, take } from "rxjs/operators"
import { configuration } from "~/_"
import {
Keyboard,
getActiveElement,
getElementOrThrow,
requestJSON
getElements,
requestJSON,
setElementFocus,
setElementSelection,
setToggle
} from "~/browser"
import {
SearchIndex,
@@ -50,6 +56,17 @@ export type Search =
| SearchQuery
| SearchResult
/* ----------------------------------------------------------------------------
* Helper types
* ------------------------------------------------------------------------- */
/**
* Mount options
*/
interface MountOptions {
keyboard$: Observable<Keyboard> /* Keyboard observable */
}
/* ----------------------------------------------------------------------------
* Helper functions
* ------------------------------------------------------------------------- */
@@ -73,17 +90,22 @@ function fetchSearchIndex(url: string) {
* Mount search
*
* @param el - Search element
* @param options - Options
*
* @returns Search component observable
*/
export function mountSearch(
el: HTMLElement
el: HTMLElement, { keyboard$ }: MountOptions
): Observable<Component<Search>> {
const config = configuration()
const worker = setupSearchWorker(config.search, fetchSearchIndex(
`${config.base}/search/search_index.json`
))
/* Retrieve elements */
const query = getElementOrThrow("[data-md-component=search-query]", el)
const result = getElementOrThrow("[data-md-component=search-result]", el)
/* Re-emit query when search is ready */
const { tx$, rx$ } = worker
tx$
@@ -94,18 +116,80 @@ export function mountSearch(
)
.subscribe(tx$.next.bind(tx$))
/* Mount search query component */
const query$ = mountSearchQuery(
getElementOrThrow("[data-md-component=search-query]", el),
worker
)
/* Set up search keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "search")
)
.subscribe(key => {
const active = getActiveElement()
switch (key.type) {
/* Mount search result and return component */
/* Enter: prevent form submission */
case "Enter":
if (active === query)
key.claim()
break
/* Escape or Tab: close search */
case "Escape":
case "Tab":
setToggle("search", 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(
":not(details) > [href], summary, details[open] [href]",
result
)]
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)
}
})
/* Set up global keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "global"),
)
.subscribe(key => {
switch (key.type) {
/* Open search and select query */
case "f":
case "s":
case "/":
setElementFocus(query)
setElementSelection(query)
key.claim()
break
}
})
/* Create and return component */
const query$ = mountSearchQuery(query as HTMLInputElement, worker)
return merge(
query$,
mountSearchResult(
getElementOrThrow("[data-md-component=search-result]", el),
worker, { query$ }
)
mountSearchResult(result, worker, { query$ })
)
}

View File

@@ -23,6 +23,7 @@
import "focus-visible"
import { Subject, defer, merge } from "rxjs"
import {
filter,
map,
mergeWith,
shareReplay,
@@ -32,9 +33,11 @@ import {
import { feature } from "./_"
import {
at,
getElement,
getElementOrThrow,
getElements,
watchDocument,
watchKeyboard,
watchLocation,
watchLocationTarget,
watchMedia,
@@ -64,7 +67,7 @@ import {
} from "./patches"
/* ----------------------------------------------------------------------------
* Program
* Application
* ------------------------------------------------------------------------- */
/* Yay, JavaScript is available */
@@ -75,6 +78,7 @@ document.documentElement.classList.add("js")
const document$ = watchDocument()
const location$ = watchLocation()
const target$ = watchLocationTarget()
const keyboard$ = watchKeyboard()
/* Set up media observables */
const viewport$ = watchViewport()
@@ -90,6 +94,32 @@ setupClipboardJS({ alert$ })
if (feature("navigation.instant"))
setupInstantLoading({ document$, location$, viewport$ })
/* Set up global keyboard handlers */
keyboard$
.pipe(
filter(({ mode }) => mode === "global")
)
.subscribe(key => {
switch (key.type) {
/* Go to previous page */
case "p":
case ",":
const prev = getElement("[href][rel=prev]")
if (typeof prev !== "undefined")
prev.click()
break
/* Go to next page */
case "n":
case ".":
const next = getElement("[href][rel=next]")
if (typeof next !== "undefined")
next.click()
break
}
})
/* Set up patches */
patchIndeterminate({ document$ })
patchScrollfix({ document$ })
@@ -121,7 +151,7 @@ const control$ = merge(
/* Search */
...getElements("[data-md-component=search]")
.map(child => mountSearch(child)),
.map(child => mountSearch(child, { keyboard$ })),
/* Repository information */
...getElements("[data-md-component=source]")
@@ -162,16 +192,18 @@ const component$ = document$
mergeWith(control$)
)
/* Subscribe to all components */
component$.subscribe()
/* Export to window */
export {
document$,
component$,
viewport$,
location$,
target$,
screen$,
keyboard$,
viewport$,
tablet$,
print$
screen$,
print$,
component$
}