fix: add skip link and semantic HTML landmarks
This commit is contained in:
77
src/App.tsx
77
src/App.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user