feat(frontend): keyboard nav for the viewer, tag editor and filter
Viewer: j/k mirror the arrow keys, and `e` scrolls to the (lazy) Tags section and drops the cursor into its filter, forcing the load so focus lands even before the section is reached. Tag picker & filter bar: from the search input, ↓/↑ highlight a suggestion and Enter adds it (focus stays for chaining); with the input empty ←/→ walk the added tags/tokens and Del removes the focused one. The filter bar also inserts an operator token on & | ! ( ), applies on Ctrl+Enter, resets on Ctrl+Backspace and closes on Esc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -184,17 +184,44 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Keyboard ----
|
// ---- Keyboard ----
|
||||||
|
let tagsSection = $state<HTMLElement>();
|
||||||
|
let pendingTagFocus = false;
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === 'ArrowLeft' || e.key === 'k') {
|
||||||
if (prevId) onNavigate(prevId);
|
if (prevId) onNavigate(prevId);
|
||||||
} else if (e.key === 'ArrowRight') {
|
} else if (e.key === 'ArrowRight' || e.key === 'j') {
|
||||||
if (nextId) onNavigate(nextId);
|
if (nextId) onNavigate(nextId);
|
||||||
|
} else if (e.key === 'e') {
|
||||||
|
e.preventDefault();
|
||||||
|
jumpToTags();
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scroll the (lazily loaded) Tags section into view and drop the cursor into
|
||||||
|
// its filter. Forces the load so the focus lands even before the user reaches
|
||||||
|
// the section by scrolling.
|
||||||
|
function jumpToTags() {
|
||||||
|
tagsVisible = true;
|
||||||
|
tagsSection?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
pendingTagFocus = true;
|
||||||
|
focusTagInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusTagInput() {
|
||||||
|
requestAnimationFrame(() => tagsSection?.querySelector<HTMLInputElement>('input')?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (tagsLoaded && pendingTagFocus) {
|
||||||
|
pendingTagFocus = false;
|
||||||
|
focusTagInput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
function formatDatetime(iso: string | null | undefined): string {
|
function formatDatetime(iso: string | null | undefined): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
@@ -344,7 +371,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Tags (loaded lazily on scroll) -->
|
<!-- Tags (loaded lazily on scroll) -->
|
||||||
<section class="section" use:tagsSentinel>
|
<section class="section" use:tagsSentinel bind:this={tagsSection}>
|
||||||
<div class="field-label">Tags</div>
|
<div class="field-label">Tags</div>
|
||||||
{#if tagsLoaded}
|
{#if tagsLoaded}
|
||||||
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
|
||||||
|
|||||||
@@ -53,6 +53,71 @@
|
|||||||
onApply(null);
|
onApply(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Keyboard navigation (from the search input) ----
|
||||||
|
// ↓/↑ highlight a tag, Enter adds it as a token; the operator chars insert an
|
||||||
|
// operator token; with the input empty ←/→ walk the active tokens and Del
|
||||||
|
// removes the focused one. Mod+Enter applies, Mod+Backspace resets, Esc closes.
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
let tokenFocusIdx = $state(-1);
|
||||||
|
const OP_KEYS = ['&', '|', '!', '(', ')'];
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (highlightIdx > filteredTags.length - 1) {
|
||||||
|
highlightIdx = Math.max(0, filteredTags.length - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSearchKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Backspace') {
|
||||||
|
e.preventDefault();
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
|
||||||
|
if (OP_KEYS.includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
addToken(e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
tokenFocusIdx = -1;
|
||||||
|
if (filteredTags.length) highlightIdx = Math.min(highlightIdx + 1, filteredTags.length - 1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
tokenFocusIdx = -1;
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
const tag = filteredTags[highlightIdx];
|
||||||
|
if (tag?.id) {
|
||||||
|
e.preventDefault();
|
||||||
|
addToken(`t=${tag.id}`);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowRight' && search === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = tokens.length;
|
||||||
|
if (n) tokenFocusIdx = tokenFocusIdx < 0 ? 0 : Math.min(tokenFocusIdx + 1, n - 1);
|
||||||
|
} else if (e.key === 'ArrowLeft' && search === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = tokens.length;
|
||||||
|
if (n) tokenFocusIdx = tokenFocusIdx < 0 ? n - 1 : Math.max(tokenFocusIdx - 1, 0);
|
||||||
|
} else if (e.key === 'Delete' && tokenFocusIdx >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
removeToken(tokenFocusIdx);
|
||||||
|
tokenFocusIdx = Math.min(tokenFocusIdx, tokens.length - 2);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Drag-and-drop reordering ---
|
// --- Drag-and-drop reordering ---
|
||||||
let dragIndex = $state<number | null>(null);
|
let dragIndex = $state<number | null>(null);
|
||||||
let dropIndex = $state<number | null>(null);
|
let dropIndex = $state<number | null>(null);
|
||||||
@@ -99,6 +164,7 @@
|
|||||||
class="token active-token"
|
class="token active-token"
|
||||||
class:dragging={dragIndex === i}
|
class:dragging={dragIndex === i}
|
||||||
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
|
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
|
||||||
|
class:kbfocus={tokenFocusIdx === i}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
@@ -129,14 +195,16 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search tags…"
|
placeholder="Search tags…"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
|
onkeydown={onSearchKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Tag list -->
|
<!-- Tag list -->
|
||||||
<div class="tag-list">
|
<div class="tag-list">
|
||||||
{#each filteredTags as tag (tag.id)}
|
{#each filteredTags as tag, i (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="token tag-token"
|
class="token tag-token"
|
||||||
|
class:hl={highlightIdx === i}
|
||||||
style="background-color: {tag.color
|
style="background-color: {tag.color
|
||||||
? '#' + tag.color
|
? '#' + tag.color
|
||||||
: tag.category_color
|
: tag.category_color
|
||||||
@@ -232,6 +300,11 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.active-token.kbfocus {
|
||||||
|
outline: 2px solid var(--color-danger);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.op-token {
|
.op-token {
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
|
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
@@ -278,6 +351,11 @@
|
|||||||
filter: brightness(1.15);
|
filter: brightness(1.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-token.hl {
|
||||||
|
outline: 2px solid var(--color-text-primary);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.no-tags {
|
.no-tags {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -64,6 +64,53 @@
|
|||||||
const color = tag.color ?? tag.category_color;
|
const color = tag.color ?? tag.category_color;
|
||||||
return color ? `background-color: #${color}` : '';
|
return color ? `background-color: #${color}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Keyboard navigation (from the search input) ----
|
||||||
|
// ↓/↑ highlight a suggestion, Enter adds it (focus stays for chaining); with the
|
||||||
|
// input empty, ←/→ walk the assigned pills and Del removes the focused one.
|
||||||
|
let highlightIdx = $state(0);
|
||||||
|
let assignedFocusIdx = $state(-1);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (highlightIdx > filteredAvailable.length - 1) {
|
||||||
|
highlightIdx = Math.max(0, filteredAvailable.length - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSearchKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
assignedFocusIdx = -1;
|
||||||
|
if (filteredAvailable.length) {
|
||||||
|
highlightIdx = Math.min(highlightIdx + 1, filteredAvailable.length - 1);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
assignedFocusIdx = -1;
|
||||||
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
const tag = filteredAvailable[highlightIdx];
|
||||||
|
if (tag?.id) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleAdd(tag.id);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'ArrowRight' && search === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = filteredAssigned.length;
|
||||||
|
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? 0 : Math.min(assignedFocusIdx + 1, n - 1);
|
||||||
|
} else if (e.key === 'ArrowLeft' && search === '') {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = filteredAssigned.length;
|
||||||
|
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? n - 1 : Math.max(assignedFocusIdx - 1, 0);
|
||||||
|
} else if (e.key === 'Delete' && assignedFocusIdx >= 0) {
|
||||||
|
const tag = filteredAssigned[assignedFocusIdx];
|
||||||
|
if (tag?.id) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleRemove(tag.id);
|
||||||
|
assignedFocusIdx = Math.min(assignedFocusIdx, filteredAssigned.length - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="picker" class:busy>
|
<div class="picker" class:busy>
|
||||||
@@ -71,9 +118,10 @@
|
|||||||
{#if fileTags.length > 0}
|
{#if fileTags.length > 0}
|
||||||
<div class="section-label">Assigned</div>
|
<div class="section-label">Assigned</div>
|
||||||
<div class="tag-row">
|
<div class="tag-row">
|
||||||
{#each filteredAssigned as tag (tag.id)}
|
{#each filteredAssigned as tag, i (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="tag assigned"
|
class="tag assigned"
|
||||||
|
class:kbfocus={assignedFocusIdx === i}
|
||||||
style={tagStyle(tag)}
|
style={tagStyle(tag)}
|
||||||
onclick={() => handleRemove(tag.id!)}
|
onclick={() => handleRemove(tag.id!)}
|
||||||
title="Remove tag"
|
title="Remove tag"
|
||||||
@@ -92,6 +140,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search tags…"
|
placeholder="Search tags…"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
|
onkeydown={onSearchKeydown}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
{#if search}
|
{#if search}
|
||||||
@@ -112,9 +161,10 @@
|
|||||||
{#if filteredAvailable.length > 0}
|
{#if filteredAvailable.length > 0}
|
||||||
<div class="section-label">Add tag</div>
|
<div class="section-label">Add tag</div>
|
||||||
<div class="tag-row available-row">
|
<div class="tag-row available-row">
|
||||||
{#each filteredAvailable as tag (tag.id)}
|
{#each filteredAvailable as tag, i (tag.id)}
|
||||||
<button
|
<button
|
||||||
class="tag available"
|
class="tag available"
|
||||||
|
class:hl={highlightIdx === i}
|
||||||
style={tagStyle(tag)}
|
style={tagStyle(tag)}
|
||||||
onclick={() => handleAdd(tag.id!)}
|
onclick={() => handleAdd(tag.id!)}
|
||||||
title="Add tag"
|
title="Add tag"
|
||||||
@@ -198,6 +248,17 @@
|
|||||||
filter: brightness(1.1);
|
filter: brightness(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag.available.hl {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.assigned.kbfocus {
|
||||||
|
outline: 2px solid var(--color-danger);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-wrap {
|
.search-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user