diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86a48bb5..690f954f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,6 +200,9 @@ importers: autoprefixer: specifier: 10.4.17 version: 10.4.17(postcss@8.4.35) + keyux: + specifier: 0.1.2 + version: 0.1.2 nanoid: specifier: 5.0.5 version: 5.0.5 @@ -6447,6 +6450,11 @@ packages: resolution: {integrity: sha512-WXzwLL0745uNuedrCsCs3rpmfD6DBaf7uuVwaq98/8dafURfgQaBsSpjiPp5+CW6Vjltwy9cOGI6qE71b3T8iQ==} dev: false + /keyux@0.1.2: + resolution: {integrity: sha512-hUXhSIdiI2BRboxv7Kn00VZCC1G5odFhzt+BY0rJH/tnJfr8jL/bULBP0J2TR9cjaFMIukoqxjP/2/I8B+Knkg==} + engines: {node: ^18.0.0 || >=20.0.0} + dev: false + /keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: diff --git a/web/lib/hotkeys.ts b/web/lib/hotkeys.ts deleted file mode 100644 index ac717665..00000000 --- a/web/lib/hotkeys.ts +++ /dev/null @@ -1,213 +0,0 @@ -interface KeyListener { - command(event: KeyboardEvent): void - element?: HTMLElement -} - -let hotkeys: Record = {} -let listeners = 0 -let pressed: HTMLElement[] = [] - -function ignoreTags(e: KeyboardEvent): boolean { - let el = e.target as Element - return el.tagName === 'TEXTAREA' || el.tagName === 'INPUT' -} - -function getListener( - e: KeyboardEvent, - cb: (listener: KeyListener) => void -): void { - if (!ignoreTags(e)) { - if (e.altKey || e.shiftKey || e.ctrlKey || e.metaKey) return - let keyListeners = hotkeys[e.key] - if (keyListeners) { - let firstListener = keyListeners[0] - if (firstListener) { - cb(firstListener) - } - } - } -} - -function onKeyDown(e: KeyboardEvent): void { - getListener(e, ({ element }) => { - markPressed(element) - }) -} - -function onKeyUp(e: KeyboardEvent): void { - getListener(e, ({ command }) => { - unmarkPressed() - command(e) - }) -} - -export function addHotkey( - key: string, - element: KeyListener['element'], - command: KeyListener['command'] -): () => void { - let listener = { command, element } - if (import.meta.env.DEV) { - if (hotkeys[key] && hotkeys[key]!.length > 1) { - alert(`Hotkey ${key} was used multiple times`) - } - } - if (!hotkeys[key]) hotkeys[key] = [] - hotkeys[key]!.unshift(listener) - - listeners += 1 - if (listeners === 1) { - window.addEventListener('keyup', onKeyUp) - window.addEventListener('keydown', onKeyDown) - } - - return () => { - hotkeys[key] = hotkeys[key]!.filter(i => i !== listener) - - listeners -= 1 - if (listeners === 0) { - window.removeEventListener('keyup', onKeyUp) - window.removeEventListener('keydown', onKeyDown) - } - } -} - -export function likelyToHavePhysicalKeyboard(): boolean { - let agent = navigator.userAgent.toLowerCase() - return !['iphone', 'ipad', 'android'].some(device => agent.includes(device)) -} - -export function markPressed(element: Element | null | undefined): void { - if (element instanceof HTMLElement) { - element.classList.add('is-pseudo-active') - pressed.push(element) - } -} - -function markHovered(element: Element | null | undefined): void { - if (element instanceof HTMLElement) { - element.classList.add('is-pseudo-hover') - pressed.push(element) - } -} - -export function unmarkPressed(): void { - for (let element of pressed) { - element.classList.remove('is-pseudo-active') - element.classList.remove('is-pseudo-hover') - } - pressed = [] -} - -interface KeyboardListener { - (event: KeyboardEvent): void -} - -export function generateMenuListeners(opts: { - getItems: (el: HTMLElement) => NodeListOf - select?: (el: HTMLElement) => void - selectOnFocus?: boolean -}): [KeyboardListener, KeyboardListener] { - function focus(prevEl: HTMLElement, nextEl: HTMLElement): void { - nextEl.tabIndex = 0 - nextEl.focus() - prevEl.tabIndex = -1 - if (opts.selectOnFocus) nextEl.click() - } - - function first(current: HTMLElement): HTMLElement { - return opts.getItems(current)[0] as HTMLElement - } - - function last(current: HTMLElement): HTMLElement { - let items = opts.getItems(current) - return items[items.length - 1] as HTMLElement - } - - function prev(current: HTMLElement): HTMLElement | undefined { - let items = opts.getItems(current) - let index = Array.from(items).indexOf(current) - return items[index - 1] as HTMLElement | undefined - } - - function next(current: HTMLElement): HTMLElement | undefined { - let items = opts.getItems(current) - let index = Array.from(items).indexOf(current) - return items[index + 1] as HTMLElement | undefined - } - - let up: KeyboardListener = e => { - unmarkPressed() - let current = e.target as HTMLElement - if (e.key === 'ArrowUp') { - focus(current, prev(current) || last(current)) - } else if (e.key === 'ArrowDown') { - focus(current, next(current) || first(current)) - } else if (e.key === 'Home') { - focus(current, first(current)) - } else if (e.key === 'End') { - focus(current, last(current)) - } else if (e.key === 'Enter') { - if (opts.select) opts.select(current) - } - } - - let down: KeyboardListener = e => { - let current = e.target as HTMLElement - let future: HTMLElement | undefined - if (e.key === 'Enter') { - if (opts.selectOnFocus) markPressed(current) - } else if (e.key === 'ArrowUp') { - future = prev(current) || last(current) - } else if (e.key === 'ArrowDown') { - future = next(current) || first(current) - } else if (e.key === 'Home') { - future = first(current) - } else if (e.key === 'End') { - future = last(current) - } - if (future) { - e.preventDefault() - if (opts.selectOnFocus) { - markPressed(future) - } else { - markHovered(future) - } - } - } - - return [down, up] -} - -let jumps: WeakRef[] = [] - -export function jumpInto(el: HTMLElement | null | undefined): void { - let current = document.activeElement - if (current instanceof HTMLElement && current !== document.body) { - jumps.push(new WeakRef(current)) - } - if (el) { - let focusable = el.querySelector( - 'button:not([tabindex="-1"]), ' + - 'a:not([tabindex="-1"]), ' + - 'input:not([tabindex="-1"]), ' + - '[tabindex="0"]' - ) - if (focusable instanceof HTMLElement) focusable.focus() - } -} - -export function jumpBack(): void { - let ref = jumps.pop() - if (!ref) { - document.documentElement.focus() - document.documentElement.blur() - return - } - let el = ref.deref() - if (el) { - el.focus() - } else { - jumpBack() - } -} diff --git a/web/main/browser.ts b/web/main/browser.ts index fc27004c..b68a0fee 100644 --- a/web/main/browser.ts +++ b/web/main/browser.ts @@ -1,6 +1,14 @@ import { isFastRoute, router, theme } from '@slowreader/core' +import { + hiddenKeyUX, + hotkeyKeyUX, + jumpKeyUX, + likelyWithKeyboard, + menuKeyUX, + pressKeyUX, + startKeyUX +} from 'keyux' -import { jumpBack, likelyToHavePhysicalKeyboard } from '../lib/hotkeys.js' import { locale } from '../stores/locale.js' let root = document.documentElement @@ -18,12 +26,14 @@ locale.subscribe(localeValue => { root.lang = localeValue }) -if (!likelyToHavePhysicalKeyboard()) { +if (!likelyWithKeyboard(window)) { root.classList.add('is-hotkey-disabled') } -document.body.addEventListener('keydown', e => { - if (e.key === 'Escape') { - if (!e.defaultPrevented) jumpBack() - } -}) +startKeyUX(window, [ + pressKeyUX('is-pseudo-active'), + hotkeyKeyUX(), + menuKeyUX(), + jumpKeyUX(), + hiddenKeyUX() +]) diff --git a/web/package.json b/web/package.json index c1a3f680..88ac474d 100644 --- a/web/package.json +++ b/web/package.json @@ -34,6 +34,7 @@ "@slowreader/core": "link:../core", "@sveltejs/vite-plugin-svelte": "3.0.2", "autoprefixer": "10.4.17", + "keyux": "0.1.2", "nanoid": "5.0.5", "nanostores": "0.9.5", "picocolors": "1.0.0", diff --git a/web/pages/feeds/add.svelte b/web/pages/feeds/add.svelte index cb2ee2e7..bee14f0c 100644 --- a/web/pages/feeds/add.svelte +++ b/web/pages/feeds/add.svelte @@ -12,11 +12,9 @@ previewUrl, previewUrlError, setPreviewCandidate, - setPreviewUrl, previewMessages as t } from '@slowreader/core' - import { jumpInto } from '../../lib/hotkeys.js' import Button from '../../ui/button.svelte' import CardLink from '../../ui/card-link.svelte' import CardLinks from '../../ui/card-links.svelte' @@ -28,7 +26,6 @@ import FeedsEdit from './edit.svelte' import FeedsPosts from './posts.svelte' - let links: HTMLUListElement let feed: HTMLDivElement @@ -36,6 +33,7 @@
0} error={$previewUrlError ? $t[$previewUrlError] : undefined} errorId={$previewNoResults ? 'feeds-add-no-results' : undefined} @@ -46,24 +44,14 @@ on:input={e => { onPreviewUrlType(e.detail.value) }} - on:enter={e => { - setPreviewUrl(e.detail.value) - if ($previewCandidates.length > 0) { - jumpInto(links) - } - }} /> {#if $previewCandidates.length > 0} - { - jumpInto(feed) - }} - > + {#each $previewCandidates as candidate (candidate.url)} { setPreviewCandidate(candidate.url) @@ -93,10 +81,10 @@
{/if} -
+
{#if $previewCandidate} {#if $previewCandidateAdded === undefined} - + {#if $previewPosts} {/if} diff --git a/web/pages/feeds/categories.svelte b/web/pages/feeds/categories.svelte index d245457a..39efba54 100644 --- a/web/pages/feeds/categories.svelte +++ b/web/pages/feeds/categories.svelte @@ -79,6 +79,7 @@ {#each feeds as feed (feed.id)} {/if}
-
+
{#if feedId} {/if} diff --git a/web/stories/ui/radio.stories.svelte b/web/stories/ui/radio.stories.svelte index c4f4e57e..30b96284 100644 --- a/web/stories/ui/radio.stories.svelte +++ b/web/stories/ui/radio.stories.svelte @@ -60,7 +60,7 @@ /> -
+
-
+
- import { createEventDispatcher, onMount } from 'svelte' + import { createEventDispatcher } from 'svelte' - import { addHotkey, markPressed, unmarkPressed } from '../lib/hotkeys.js' import Hotkey from './hotkey.svelte' import Icon from './icon.svelte' @@ -13,38 +12,15 @@ export let hiddenLabel: string | undefined = undefined export let dangerous = false - let element: HTMLAnchorElement | HTMLButtonElement - let dispatch = createEventDispatcher() function onClick(): void { dispatch('click') } - - function onKeyDown(e: KeyboardEvent): void { - if (e.key === 'Enter') { - markPressed(element) - e.preventDefault() - } - } - - function onKeyUp(e: KeyboardEvent): void { - unmarkPressed() - if (e.key === 'Enter') { - element.click() - } - } - - onMount(() => { - if (hotkey) { - return addHotkey(hotkey, element, onClick) - } - }) {#if href} {#if icon} @@ -69,7 +43,6 @@ {:else}