keyboard: board card navigation (j/k/arrows + actions)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 16s
CI & Build / Build & push image (push) Successful in 34s

Focused-card selection on the board: j/k or arrows move it, Enter opens,
e archives, # pins, x trashes the focused card (actions gated off the Trash
view). The selection is a brand ring on the card; it scrolls into view and
clamps as the list/view changes. A window keydown listener on BoardView,
ignored while typing, on a button/link, or while the editor modal is open.
Added the card keys to the ? cheat-sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-20 11:18:28 -04:00
co-authored by Claude Opus 4.8
parent b24316843e
commit 4c72dfea0b
3 changed files with 85 additions and 7 deletions
+3
View File
@@ -30,6 +30,9 @@ const shortcuts = [
{ label: "Go to Board", keys: ["g", "b"] },
{ label: "Go to Graph", keys: ["g", "g"] },
{ label: "Go to Reminders", keys: ["g", "r"] },
{ label: "Move card focus", keys: ["j", "k"] },
{ label: "Open focused card", keys: ["Enter"] },
{ label: "Pin / archive / trash card", keys: ["#", "e", "x"] },
{ label: "Save & close (editor)", keys: ["⌘/Ctrl", "Enter"] },
{ label: "Save & new (composer)", keys: ["Shift", "Enter"] },
{ label: "Dismiss / close", keys: ["Esc"] },
+13 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useNotesStore } from "../stores/notes";
import { NOTE_CARD_CLASSES, type NoteColor } from "../notes/colors";
import type { Note } from "../stores/notes";
@@ -7,7 +8,7 @@ import LinkedText from "./LinkedText.vue";
import NoteChecklist from "./NoteChecklist.vue";
import { formatReminder, isOverdue } from "../notes/datetime";
defineProps<{ note: Note; reorderable?: boolean }>();
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
const emit = defineEmits<{
(e: "open", note: Note): void;
(e: "dragstart", note: Note): void;
@@ -15,6 +16,15 @@ const emit = defineEmits<{
}>();
const notes = useNotesStore();
// When this card becomes the keyboard-focused card, scroll it into view.
const root = ref<HTMLElement | null>(null);
watch(
() => props.active,
(a) => {
if (a) root.value?.scrollIntoView({ block: "nearest", behavior: "smooth" });
},
);
function cardClass(color: NoteColor): string {
return NOTE_CARD_CLASSES[color] ?? NOTE_CARD_CLASSES.default;
}
@@ -22,8 +32,9 @@ function cardClass(color: NoteColor): string {
<template>
<div
ref="root"
class="group relative mb-4 break-inside-avoid rounded-xl border p-3 shadow-sm transition hover:shadow-md"
:class="cardClass(note.color)"
:class="[cardClass(note.color), active ? 'ring-2 ring-brand' : '']"
:draggable="reorderable && !note.trashed"
@dragstart="emit('dragstart', note)"
@dragover.prevent
+69 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useNotesStore, type Note, type NoteView } from "../stores/notes";
import { useUiStore } from "../stores/ui";
@@ -52,6 +52,63 @@ const isMainBoard = computed(() => route.name === "board");
const pinnedNotes = computed(() => notes.items.filter((n) => n.pinned));
const otherNotes = computed(() => notes.items.filter((n) => !n.pinned));
// --- Keyboard card navigation: j/k or arrows move a selection; Enter opens;
// e archive, # pin, x trash the focused card. ---
const orderedNotes = computed<Note[]>(() =>
isMainBoard.value ? [...pinnedNotes.value, ...otherNotes.value] : notes.items,
);
const focusedIndex = ref(-1);
const inTrash = computed(() => currentView.value === "trash");
function moveFocus(delta: number) {
const count = orderedNotes.value.length;
if (count === 0) return;
focusedIndex.value =
focusedIndex.value < 0 ? 0 : Math.min(Math.max(focusedIndex.value + delta, 0), count - 1);
}
function onBoardKey(e: KeyboardEvent) {
if (editing.value) return; // the editor modal owns the keyboard while open
const el = e.target as HTMLElement | null;
if (el && (["INPUT", "TEXTAREA", "BUTTON", "A"].includes(el.tagName) || el.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key;
if (key === "j" || key === "ArrowDown" || key === "ArrowRight") {
e.preventDefault();
moveFocus(1);
return;
}
if (key === "k" || key === "ArrowUp" || key === "ArrowLeft") {
e.preventDefault();
moveFocus(-1);
return;
}
const note = orderedNotes.value[focusedIndex.value];
if (!note) return;
if (key === "Enter") {
e.preventDefault();
openEditor(note);
} else if (key === "x" && !inTrash.value) {
e.preventDefault();
void notes.trash(note.id);
} else if (key === "e" && !inTrash.value) {
e.preventDefault();
void notes.setArchived(note.id, !note.archived);
} else if (key === "#" && !inTrash.value) {
e.preventDefault();
void notes.setPinned(note.id, !note.pinned);
}
}
// Keep the selection valid as the list changes / the view switches.
watch(
() => orderedNotes.value.length,
(len) => {
if (focusedIndex.value >= len) focusedIndex.value = len - 1;
},
);
watch([currentView, currentLabel], () => (focusedIndex.value = -1));
const emptyState = computed(() => {
if (currentView.value === "trash") return { title: "Trash is empty", subtitle: "Notes you delete land here first." };
if (currentView.value === "archived")
@@ -64,7 +121,11 @@ async function reload() {
await notes.load(currentView.value, currentLabel.value);
}
onMounted(reload);
onMounted(() => {
void reload();
window.addEventListener("keydown", onBoardKey);
});
onBeforeUnmount(() => window.removeEventListener("keydown", onBoardKey));
watch([currentView, currentLabel], reload);
function openEditor(note: Note) {
@@ -119,9 +180,10 @@ async function onDrop(target: Note) {
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-400">Pinned</h2>
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard
v-for="n in pinnedNotes"
v-for="(n, i) in pinnedNotes"
:key="n.id"
:note="n"
:active="focusedIndex === i"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@@ -135,9 +197,10 @@ async function onDrop(target: Note) {
</h2>
<div class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard
v-for="n in otherNotes"
v-for="(n, i) in otherNotes"
:key="n.id"
:note="n"
:active="focusedIndex === pinnedNotes.length + i"
reorderable
@open="openEditor"
@dragstart="onDragStart"
@@ -149,9 +212,10 @@ async function onDrop(target: Note) {
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard
v-for="n in notes.items"
v-for="(n, i) in notes.items"
:key="n.id"
:note="n"
:active="focusedIndex === i"
reorderable
@open="openEditor"
@dragstart="onDragStart"