From b280beed7df2255f3f64400cc5d4155fb013e798 Mon Sep 17 00:00:00 2001 From: Oliver Bryan Date: Sat, 7 Feb 2026 15:56:27 +0000 Subject: [PATCH] full keyboard navigation --- src/App.tsx | 106 +++++++++++++++++++++++++++-- src/components/ProjectListItem.tsx | 8 ++- src/components/ProjectPage.tsx | 63 ++++++++++++++--- 3 files changed, 161 insertions(+), 16 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7b08b20a..206d144b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import { Notes, } from "@nsmr/pixelart-react"; import { useEffect, useState } from "react"; -import { Link, Route, Routes, useParams } from "react-router-dom"; +import { Link, Route, Routes, useNavigate, useParams } from "react-router-dom"; import { AskAI } from "@/components/ask-ai"; import { ProjectListItem } from "@/components/ProjectListItem"; import { TimeSince } from "@/components/time-since"; @@ -39,7 +39,10 @@ export default App; function Home() { const isDevMode = import.meta.env.VITE_PUBLIC_DEV === "1"; + const navigate = useNavigate(); const [asciiArt, setAsciiArt] = useState(""); + const [activeIndex, setActiveIndex] = useState(null); + const [hasPointerInteraction, setHasPointerInteraction] = useState(false); const [asciiFile] = useState( () => asciiFiles[Math.floor(Math.random() * asciiFiles.length)], ); @@ -48,6 +51,9 @@ function Home() { parseDate(b.metadata.date).getTime() - parseDate(a.metadata.date).getTime(), ); + const visibleProjects = sortedProjects.filter( + (project) => isDevMode || !project.metadata.hidden, + ); useEffect(() => { let isActive = true; @@ -61,6 +67,78 @@ function Home() { }; }, [asciiFile]); + useEffect(() => { + setActiveIndex((prev) => { + if (visibleProjects.length === 0) return null; + if (prev === null) return null; + return Math.min(prev, visibleProjects.length - 1); + }); + }, [visibleProjects.length]); + + useEffect(() => { + if (visibleProjects.length === 0) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isInteractiveTarget(event.target)) return; + + const key = event.key.length === 1 ? event.key.toLowerCase() : event.key; + const isDesktop = window.matchMedia("(min-width: 768px)").matches; + const columns = isDesktop ? 2 : 1; + + let delta = 0; + if (key === "ArrowLeft" || key === "h") delta = -1; + if (key === "ArrowRight" || key === "l") delta = 1; + if (key === "ArrowUp" || key === "k") delta = -columns; + if (key === "ArrowDown" || key === "j") delta = columns; + + if (delta !== 0) { + event.preventDefault(); + setActiveIndex((prev) => { + if (prev === null) return 0; + const next = Math.max( + 0, + Math.min(visibleProjects.length - 1, prev + delta), + ); + return next; + }); + return; + } + + if (key === "Enter") { + if (activeIndex === null) return; + event.preventDefault(); + const target = visibleProjects[activeIndex]; + if (!target) return; + navigate(`/projects/${target.metadata.slug}`); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [activeIndex, navigate, visibleProjects]); + + useEffect(() => { + const enablePointerInteraction = () => { + setHasPointerInteraction(true); + }; + + window.addEventListener("pointermove", enablePointerInteraction, { + once: true, + }); + window.addEventListener("pointerdown", enablePointerInteraction, { + once: true, + }); + + return () => { + window.removeEventListener("pointermove", enablePointerInteraction); + window.removeEventListener("pointerdown", enablePointerInteraction); + }; + }, []); + return (
@@ -103,24 +181,44 @@ function Home() {
- {sortedProjects.map((project) => ( + {visibleProjects.map((project, index) => ( ))}
-
+
- +

+ arrows or hjkl, then enter +

+
+ +
); } +function isInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tagName = target.tagName; + return ( + tagName === "INPUT" || + tagName === "TEXTAREA" || + tagName === "SELECT" || + tagName === "BUTTON" || + tagName === "A" + ); +} + function ProjectRoute() { const { slug } = useParams(); if (!slug || !projects[slug]) return ; diff --git a/src/components/ProjectListItem.tsx b/src/components/ProjectListItem.tsx index 5ad67ac0..f9cf2542 100644 --- a/src/components/ProjectListItem.tsx +++ b/src/components/ProjectListItem.tsx @@ -5,9 +5,13 @@ import type { ProjectMetadata } from "@/projects"; export function ProjectListItem({ metadata, isDevMode = false, + isActive = false, + enableHover = true, }: { metadata: ProjectMetadata; isDevMode?: boolean; + isActive?: boolean; + enableHover?: boolean; }) { const tags = metadata.tags ? [...metadata.tags].sort() : []; if (metadata.hidden && !isDevMode) return null; @@ -16,7 +20,9 @@ export function ProjectListItem({ { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.isComposing) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isInteractiveTarget(event.target)) return; + + if ( + event.key === "Escape" || + event.key === "Backspace" || + event.key === "q" + ) { + event.preventDefault(); + navigate("/"); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [navigate]); + return ( -
+
+

+ esc or backspace to go back +

)}
- +
+ +
{metadata.url ? (
@@ -112,3 +140,16 @@ export function ProjectPage({
); } + +function isInteractiveTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tagName = target.tagName; + return ( + tagName === "INPUT" || + tagName === "TEXTAREA" || + tagName === "SELECT" || + tagName === "BUTTON" || + tagName === "A" + ); +}