keyboard: board card navigation (j/k/arrows + actions)
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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user