fix: add skip link and semantic HTML landmarks

This commit is contained in:
Your Name
2026-02-19 21:16:58 +02:00
parent ecb9fd867c
commit f550dba3b9
2 changed files with 71 additions and 26 deletions

View File

@@ -1,8 +1,10 @@
import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { readTextFile } from "@tauri-apps/plugin-fs"; import { readTextFile } from "@tauri-apps/plugin-fs";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import hljs from "highlight.js"; import hljs from "highlight.js";
@@ -337,6 +339,21 @@ function App() {
}; };
}, [activeTab, uiZoom]); }, [activeTab, uiZoom]);
// Open external links in default OS browser
useEffect(() => {
const handleLinkClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement).closest('a[href]') as HTMLAnchorElement | null;
if (!anchor) return;
const href = anchor.getAttribute('href');
if (href && /^https?:\/\//.test(href)) {
e.preventDefault();
openUrl(href);
}
};
document.addEventListener('click', handleLinkClick);
return () => document.removeEventListener('click', handleLinkClick);
}, []);
// Clear selection overlay when context menu closes // Clear selection overlay when context menu closes
useEffect(() => { useEffect(() => {
if (!contextMenu) setSelectionRects(prev => prev.length > 0 ? [] : prev); if (!contextMenu) setSelectionRects(prev => prev.length > 0 ? [] : prev);
@@ -514,25 +531,32 @@ function App() {
}, [parseHeadings]); }, [parseHeadings]);
// Handle file opened via OS file association (e.g. double-clicking a .md file) // Handle file opened via OS file association (e.g. double-clicking a .md file)
useEffect(() => {
invoke<{ path: string; content: string } | null>('get_cli_file').then(result => {
if (!result) return;
const title = result.path.split(/[/\\]/).pop() || 'Untitled';
const newTab: Tab = { id: Date.now().toString(), title, content: result.content, path: result.path };
setTabs(prev => [...prev, newTab]);
setActiveTabId(newTab.id);
parseHeadings(result.content);
}).catch(err => console.error('Failed to open file from CLI args:', err));
}, []);
// Handle files opened from a second instance (single-instance plugin forwards them here)
useEffect(() => { useEffect(() => {
let unlisten: (() => void) | undefined; let unlisten: (() => void) | undefined;
listen<string>('open-file', async (event) => { listen<{ path: string; content: string }>('open-file', (event) => {
const filePath = event.payload; const { path: filePath, content } = event.payload;
try { const existing = tabsRef.current.find(t => t.path === filePath);
const existing = tabsRef.current.find(t => t.path === filePath); if (existing) {
if (existing) { setActiveTabId(existing.id);
setActiveTabId(existing.id); parseHeadings(existing.content);
parseHeadings(existing.content); } else {
} else { const title = filePath.split(/[/\\]/).pop() || 'Untitled';
const content = await readTextFile(filePath); const newTab: Tab = { id: Date.now().toString(), title, content, path: filePath };
const title = filePath.split(/[/\\]/).pop() || 'Untitled'; setTabs(prev => [...prev, newTab]);
const newTab: Tab = { id: Date.now().toString(), title, content, path: filePath }; setActiveTabId(newTab.id);
setTabs(prev => [...prev, newTab]); parseHeadings(content);
setActiveTabId(newTab.id);
parseHeadings(content);
}
} catch (err) {
console.error('Failed to open file from OS:', err);
} }
}).then(fn => { unlisten = fn; }); }).then(fn => { unlisten = fn; });
return () => { unlisten?.(); }; return () => { unlisten?.(); };
@@ -726,6 +750,7 @@ function App() {
return ( return (
<div className={`app-container ${focusMode ? 'focus-mode' : ''}`} style={{ zoom: `${uiZoom}%` }}> <div className={`app-container ${focusMode ? 'focus-mode' : ''}`} style={{ zoom: `${uiZoom}%` }}>
<a href="#main-content" className="skip-link">Skip to content</a>
<AnimatePresence> <AnimatePresence>
{isDraggingOver && ( {isDraggingOver && (
<motion.div className="drop-zone" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15, ease: materialEase }}> <motion.div className="drop-zone" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.15, ease: materialEase }}>
@@ -742,7 +767,7 @@ function App() {
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{!focusMode && ( {!focusMode && (
<motion.div key="title-bar" className="title-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 32, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={smoothTransition} style={{ overflow: 'hidden' }}> <motion.header key="title-bar" className="title-bar" initial={{ height: 0, opacity: 0 }} animate={{ height: 32, opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={smoothTransition} style={{ overflow: 'hidden' }}>
<div className="title-bar-left"> <div className="title-bar-left">
<div className="title-bar-icon"><FileText size={14} /></div> <div className="title-bar-icon"><FileText size={14} /></div>
<span className="title-bar-text">Vesper</span> <span className="title-bar-text">Vesper</span>
@@ -753,7 +778,7 @@ function App() {
<button className="title-bar-button" onClick={() => appWindow?.toggleMaximize()}><Square size={12} /></button> <button className="title-bar-button" onClick={() => appWindow?.toggleMaximize()}><Square size={12} /></button>
<button className="title-bar-button close" onClick={closeWindow}><X size={14} /></button> <button className="title-bar-button close" onClick={closeWindow}><X size={14} /></button>
</div> </div>
</motion.div> </motion.header>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -821,10 +846,10 @@ function App() {
<div className="main-content"> <div className="main-content">
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{showSidebar && ( {showSidebar && (
<motion.div className="sidebar-scroll-wrapper" initial={{ width: 0, opacity: 0 }} animate={{ width: sidebarWidth, opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={resizeStartState ? { duration: 0 } : smoothTransition} style={{ height: '100%' }}> <motion.nav className="sidebar-scroll-wrapper" aria-label="Table of Contents" initial={{ width: 0, opacity: 0 }} animate={{ width: sidebarWidth, opacity: 1 }} exit={{ width: 0, opacity: 0 }} transition={resizeStartState ? { duration: 0 } : smoothTransition} style={{ height: '100%' }}>
<OverlayScrollbarsComponent options={osScrollbarOptions} style={{ height: '100%' }}> <OverlayScrollbarsComponent options={osScrollbarOptions} style={{ height: '100%' }}>
<div className="sidebar" ref={sidebarRef}> <div className="sidebar" ref={sidebarRef}>
<div className="sidebar-heading">Contents</div> <h2 className="sidebar-heading">Contents</h2>
{headings.length > 0 {headings.length > 0
? headings.map(h => <button key={h.id} className={`sidebar-item h${h.level}`} onClick={() => scrollToHeading(h.text)}>{h.text}</button>) ? headings.map(h => <button key={h.id} className={`sidebar-item h${h.level}`} onClick={() => scrollToHeading(h.text)}>{h.text}</button>)
: <div className="sidebar-empty">No headings</div> : <div className="sidebar-empty">No headings</div>
@@ -832,14 +857,14 @@ function App() {
</div> </div>
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
<div className="sidebar-resize-handle" onMouseDown={startSidebarResize}></div> <div className="sidebar-resize-handle" onMouseDown={startSidebarResize}></div>
</motion.div> </motion.nav>
)} )}
</AnimatePresence> </AnimatePresence>
<div className="content-column"> <main id="main-content" className="content-column">
<AnimatePresence> <AnimatePresence>
{showSearch && activeTab && ( {showSearch && activeTab && (
<motion.div className="search-bar" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={smoothTransition}> <motion.search className="search-bar" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }} transition={smoothTransition}>
<input ref={searchInputRef} type="text" className="search-input" placeholder="Search..." value={searchQuery} onChange={e => { setSearchQuery(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setCurrentMatchIndex(i => (i + 1) % Math.max(searchMatches.length, 1)); setSearchTriggerCount(c => c + 1); } }} autoFocus /> <input ref={searchInputRef} type="text" className="search-input" placeholder="Search..." value={searchQuery} onChange={e => { setSearchQuery(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setCurrentMatchIndex(i => (i + 1) % Math.max(searchMatches.length, 1)); setSearchTriggerCount(c => c + 1); } }} autoFocus />
{searchMatches.length > 0 && ( {searchMatches.length > 0 && (
<> <>
@@ -849,7 +874,7 @@ function App() {
</> </>
)} )}
{searchQuery && searchMatches.length === 0 && <span className="search-results">No results</span>} {searchQuery && searchMatches.length === 0 && <span className="search-results">No results</span>}
</motion.div> </motion.search>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -870,7 +895,7 @@ function App() {
</div> </div>
</OverlayScrollbarsComponent> </OverlayScrollbarsComponent>
</motion.div> </motion.div>
</div> </main>
</div> </div>
<AnimatePresence> <AnimatePresence>

View File

@@ -153,6 +153,26 @@ html, body, #root {
overflow: visible; overflow: visible;
} }
/* Skip link — WCAG 2.4.1 */
.skip-link {
position: fixed;
top: -100%;
left: 16px;
z-index: 99999;
padding: 8px 16px;
background-color: var(--color-accent);
color: white;
font-size: 13px;
font-weight: 600;
border-radius: var(--radius-md);
text-decoration: none;
transition: top 0.2s ease;
}
.skip-link:focus {
top: 8px;
}
/* App container */ /* App container */
.app-container { .app-container {
display: flex; display: flex;