fix: add skip link and semantic HTML landmarks
This commit is contained in:
57
src/App.tsx
57
src/App.tsx
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import hljs from "highlight.js";
|
||||
@@ -337,6 +339,21 @@ function App() {
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!contextMenu) setSelectionRects(prev => prev.length > 0 ? [] : prev);
|
||||
@@ -514,26 +531,33 @@ function App() {
|
||||
}, [parseHeadings]);
|
||||
|
||||
// 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(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
listen<string>('open-file', async (event) => {
|
||||
const filePath = event.payload;
|
||||
try {
|
||||
listen<{ path: string; content: string }>('open-file', (event) => {
|
||||
const { path: filePath, content } = event.payload;
|
||||
const existing = tabsRef.current.find(t => t.path === filePath);
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id);
|
||||
parseHeadings(existing.content);
|
||||
} else {
|
||||
const content = await readTextFile(filePath);
|
||||
const title = filePath.split(/[/\\]/).pop() || 'Untitled';
|
||||
const newTab: Tab = { id: Date.now().toString(), title, content, path: filePath };
|
||||
setTabs(prev => [...prev, newTab]);
|
||||
setActiveTabId(newTab.id);
|
||||
parseHeadings(content);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to open file from OS:', err);
|
||||
}
|
||||
}).then(fn => { unlisten = fn; });
|
||||
return () => { unlisten?.(); };
|
||||
}, [parseHeadings]);
|
||||
@@ -726,6 +750,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className={`app-container ${focusMode ? 'focus-mode' : ''}`} style={{ zoom: `${uiZoom}%` }}>
|
||||
<a href="#main-content" className="skip-link">Skip to content</a>
|
||||
<AnimatePresence>
|
||||
{isDraggingOver && (
|
||||
<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}>
|
||||
{!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-icon"><FileText size={14} /></div>
|
||||
<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 close" onClick={closeWindow}><X size={14} /></button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.header>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -821,10 +846,10 @@ function App() {
|
||||
<div className="main-content">
|
||||
<AnimatePresence initial={false}>
|
||||
{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%' }}>
|
||||
<div className="sidebar" ref={sidebarRef}>
|
||||
<div className="sidebar-heading">Contents</div>
|
||||
<h2 className="sidebar-heading">Contents</h2>
|
||||
{headings.length > 0
|
||||
? 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>
|
||||
@@ -832,14 +857,14 @@ function App() {
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
<div className="sidebar-resize-handle" onMouseDown={startSidebarResize}></div>
|
||||
</motion.div>
|
||||
</motion.nav>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="content-column">
|
||||
<main id="main-content" className="content-column">
|
||||
<AnimatePresence>
|
||||
{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 />
|
||||
{searchMatches.length > 0 && (
|
||||
<>
|
||||
@@ -849,7 +874,7 @@ function App() {
|
||||
</>
|
||||
)}
|
||||
{searchQuery && searchMatches.length === 0 && <span className="search-results">No results</span>}
|
||||
</motion.div>
|
||||
</motion.search>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -870,7 +895,7 @@ function App() {
|
||||
</div>
|
||||
</OverlayScrollbarsComponent>
|
||||
</motion.div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -153,6 +153,26 @@ html, body, #root {
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user