feat(frontend): global keyboard navigation and shortcuts help overlay
Adds a single window-level key dispatcher in the root layout: `g` then c/t/f/p/s (or the digits 1–5) jump between the five sections, honouring each section's remembered URL so you land back on the same filter and scroll. `?` toggles a shortcuts cheat-sheet overlay. The handler stays out of the way while typing in inputs or when a browser/OS modifier is held. This is the foundation for the per-context keymaps (grid, viewer, tag/filter pickers) that follow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,192 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onClose }: Props = $props();
|
||||||
|
|
||||||
|
// Static cheat-sheet of the app's shortcuts, grouped by context. Kept in sync
|
||||||
|
// by hand with the per-context handlers (global nav here, the rest on the
|
||||||
|
// Files page / viewer / tag pickers).
|
||||||
|
const groups: { title: string; rows: [string, string][] }[] = [
|
||||||
|
{
|
||||||
|
title: 'Anywhere',
|
||||||
|
rows: [
|
||||||
|
['g then c / t / f / p / s', 'Go to Categories / Tags / Files / Pools / Settings'],
|
||||||
|
['1 – 5', 'Jump to a section'],
|
||||||
|
['?', 'Toggle this help'],
|
||||||
|
['/', 'Focus the filter / search']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'File grid',
|
||||||
|
rows: [
|
||||||
|
['↑ ↓ ← →', 'Move focus between files'],
|
||||||
|
['Enter', 'Open the focused file'],
|
||||||
|
['Space / x', 'Select / deselect'],
|
||||||
|
['e', 'Edit tags (focus the tag filter)'],
|
||||||
|
['p', 'Add to pool'],
|
||||||
|
['Del', 'Move to trash'],
|
||||||
|
['Esc', 'Clear selection']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Viewer',
|
||||||
|
rows: [
|
||||||
|
['← / → or j / k', 'Previous / next file'],
|
||||||
|
['e', 'Jump to tags & focus the filter'],
|
||||||
|
['Esc', 'Close']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tag editor / filter',
|
||||||
|
rows: [
|
||||||
|
['↓ ↑', 'Highlight a suggestion'],
|
||||||
|
['Enter', 'Add the highlighted tag'],
|
||||||
|
['← →', 'Move across added tags / tokens (empty input)'],
|
||||||
|
['Del', 'Remove the focused tag / token'],
|
||||||
|
['& | ! ( )', 'Insert an operator (filter only)'],
|
||||||
|
['Ctrl+Enter', 'Apply the filter'],
|
||||||
|
['Ctrl+Backspace', 'Reset the filter'],
|
||||||
|
['Esc', 'Close']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div class="backdrop" role="presentation" onclick={onClose}></div>
|
||||||
|
<div class="sheet" role="dialog" aria-label="Keyboard shortcuts" aria-modal="true">
|
||||||
|
<div class="head">
|
||||||
|
<span class="title">Keyboard shortcuts</span>
|
||||||
|
<button class="close" onclick={onClose} aria-label="Close">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M3 3l10 10M13 3L3 13"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
{#each groups as group}
|
||||||
|
<section class="group">
|
||||||
|
<h3 class="group-title">{group.title}</h3>
|
||||||
|
{#each group.rows as [keys, desc]}
|
||||||
|
<div class="row">
|
||||||
|
<kbd class="keys">{keys}</kbd>
|
||||||
|
<span class="desc">{desc}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 300;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 301;
|
||||||
|
width: min(560px, calc(100vw - 24px));
|
||||||
|
max-height: min(80dvh, 640px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--color-bg-secondary);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
animation: pop 0.16s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pop {
|
||||||
|
from {
|
||||||
|
transform: translate(-50%, -48%) scale(0.98);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 16px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 0 16px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||||
|
gap: 6px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
break-inside: avoid;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-title {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-accent);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-bg-elevated);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-accent) 25%, transparent);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { afterNavigate } from '$app/navigation';
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
import { themeStore, toggleTheme } from '$lib/stores/theme';
|
import { themeStore, toggleTheme } from '$lib/stores/theme';
|
||||||
|
import KeyboardHelp from '$lib/components/layout/KeyboardHelp.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -51,10 +52,84 @@
|
|||||||
const item = navItems.find((it) => it.match === url.pathname);
|
const item = navItems.find((it) => it.match === url.pathname);
|
||||||
if (item) lastUrl[item.match] = url.pathname + url.search;
|
if (item) lastUrl[item.match] = url.pathname + url.search;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Global keyboard navigation -----------------------------------------
|
||||||
|
let helpOpen = $state(false);
|
||||||
|
|
||||||
|
// g-then-letter and 1–5 jump between sections; both honour the remembered
|
||||||
|
// per-section URL so you land back on the same filter/scroll.
|
||||||
|
const G_MAP: Record<string, string> = {
|
||||||
|
c: '/categories',
|
||||||
|
t: '/tags',
|
||||||
|
f: '/files',
|
||||||
|
p: '/pools',
|
||||||
|
s: '/settings'
|
||||||
|
};
|
||||||
|
const NUM_MAP = navItems.map((it) => it.match); // 1→categories … 5→settings
|
||||||
|
|
||||||
|
let pendingG = false;
|
||||||
|
let gTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function go(match: string) {
|
||||||
|
goto(lastUrl[match] ?? match);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditable(t: EventTarget | null): boolean {
|
||||||
|
return (
|
||||||
|
t instanceof HTMLElement &&
|
||||||
|
(t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGlobalKey(e: KeyboardEvent) {
|
||||||
|
if (helpOpen && e.key === 'Escape') {
|
||||||
|
helpOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Stay out of the way while typing or when a browser/OS combo is held.
|
||||||
|
if (isEditable(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
if (isLogin) return;
|
||||||
|
|
||||||
|
if (e.key === '?') {
|
||||||
|
helpOpen = !helpOpen;
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingG) {
|
||||||
|
pendingG = false;
|
||||||
|
clearTimeout(gTimer);
|
||||||
|
const dest = G_MAP[e.key.toLowerCase()];
|
||||||
|
if (dest) {
|
||||||
|
e.preventDefault();
|
||||||
|
go(dest);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'g') {
|
||||||
|
pendingG = true;
|
||||||
|
clearTimeout(gTimer);
|
||||||
|
gTimer = setTimeout(() => (pendingG = false), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key >= '1' && e.key <= '5') {
|
||||||
|
const dest = NUM_MAP[Number(e.key) - 1];
|
||||||
|
if (dest) {
|
||||||
|
e.preventDefault();
|
||||||
|
go(dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onGlobalKey} />
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
|
{#if helpOpen}
|
||||||
|
<KeyboardHelp onClose={() => (helpOpen = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if !isLogin && !isAdmin}
|
{#if !isLogin && !isAdmin}
|
||||||
<footer>
|
<footer>
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
|
|||||||
Reference in New Issue
Block a user