mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
fix(web): keyboard and screen-reader fixes on sources, files, home and the nav
- 书源与追更 tabs are a proper tablist: one tab stop, arrow/Home/End keys, aria-controls on a labelled panel. The tab is kept in ?tab=, so a reload or back from an editor returns to it. - 文件管理 rows are no longer tab stops that do nothing (their check, folder name and ⋯ are the controls); the current crumb is aria-current="location". - 首页's 继续阅读 card is named by its visible text (title, chapter, 已读 x%), and the sidebar account link includes the name and the connection state. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dee6175425
commit
1c16da836d
4 files changed
+25
-8
No files matched your search
@@ -74,7 +74,7 @@ export function AppNav({ mode }: { mode: Exclude<NavMode, 'hidden'> }) {
|
||||
</li>;
|
||||
})}
|
||||
</ul>
|
||||
{user && <Link className="app-nav-user" to="/me" aria-label="我的账号">
|
||||
{user && <Link className="app-nav-user" to="/me" aria-label={`我的账号:${name},${connection.label}`}>
|
||||
<span className="app-nav-avatar" aria-hidden="true">{Array.from(name)[0]}</span>
|
||||
<span className="app-nav-user-text"><strong>{name}</strong><small title={host(runtime.api.baseUrl)}><i className="app-nav-status" data-state={connection.state} aria-hidden="true" />{connection.label}</small></span>
|
||||
</Link>}
|
||||
|
||||
@@ -29,7 +29,7 @@ function Resume({ book }: { book: ContinueReadingItem }) {
|
||||
const runtime = useRuntime();
|
||||
const src = useCover(runtime.api, book.coverUrl);
|
||||
const percent = Math.round(Math.max(0, Math.min(1, book.percentage)) * 100);
|
||||
return <Link className="home-hero" to={`/book/${encodeURIComponent(book.id)}`} aria-label={`继续阅读 ${book.title}`}>
|
||||
return <Link className="home-hero" to={`/book/${encodeURIComponent(book.id)}`}>
|
||||
<span className="home-hero-cover" aria-hidden="true">{src ? <img src={src} alt="" /> : <b>{book.title}</b>}</span>
|
||||
<span className="home-hero-text">
|
||||
<strong>{book.title || '未命名'}</strong>
|
||||
|
||||
@@ -123,13 +123,13 @@ function LibraryFilesAdminPage() {
|
||||
const remove = async (paths = selected) => { if (paths.length && await confirm(paths.length === 1 ? '删除' : `删除 ${paths.length} 项`, `${describePaths(paths)}将从磁盘上删除,无法撤销。`)) await mutate(() => runtime.api.browseDelete(paths), result => ({ text: `已删除 ${result.removed} 项` })); };
|
||||
const pages = query.data ? Math.max(1, Math.ceil(query.data.total / 200)) : 1;
|
||||
useEffect(() => { if (query.data && page > pages) setParams({ ...(path ? { path } : {}), ...(pages > 1 ? { page: String(pages) } : {}) }, { replace: true }); });
|
||||
const crumbs = <div className="manager-crumbs">{(query.data?.crumbs ?? [{ name: '书库', path: '' }]).map((crumb, index, all) => <span key={`${crumb.path}-${index}`}>{index > 0 && <span className="manager-crumb-sep">/</span>}<button type="button" className="manager-crumb" aria-current={index === all.length - 1 ? 'true' : undefined} onClick={() => go(crumb.path)}>{crumb.name}</button></span>)}</div>;
|
||||
const crumbs = <div className="manager-crumbs">{(query.data?.crumbs ?? [{ name: '书库', path: '' }]).map((crumb, index, all) => <span key={`${crumb.path}-${index}`}>{index > 0 && <span className="manager-crumb-sep">/</span>}<button type="button" className="manager-crumb" aria-current={index === all.length - 1 ? 'location' : undefined} onClick={() => go(crumb.path)}>{crumb.name}</button></span>)}</div>;
|
||||
return <div className="library-screen library-files-screen"><CollectionHeader className="file-manager-header" title="文件管理" subtitle="管理书库中的文件与文件夹" backTo="/library" actions={<><label className={'button collection-link' + (!query.data?.writable ? ' is-disabled' : '')}><Upload size={18} /><span>上传书籍</span><input hidden type="file" multiple disabled={!query.data?.writable} onChange={event => { const files = Array.from(event.currentTarget.files ?? []); if (files.length) void mutate(() => runtime.api.upload(files, path, 'rename'), describeUpload); event.currentTarget.value = ''; }} /></label><button type="button" className="button collection-link" disabled={!query.data?.writable} onClick={mkdir}><Plus size={18} /><span>新建文件夹</span></button></>} footer={crumbs} />
|
||||
<main className="manager-body">
|
||||
{selected.length > 0 && <div className="manager-actions"><span className="manager-selected-count">已选 {selected.length} 项</span><button type="button" className="button" onClick={() => void move()}>移动</button><button type="button" className="button danger" onClick={() => void remove()}>删除</button><button type="button" className="button" onClick={() => setSelected([])}>清除</button></div>}
|
||||
<FloatingNotice message={feedback?.text ?? ''} error={!!feedback?.error} kind="success" />
|
||||
{query.isPending && <FloatingNotice message="正在读取文件列表…" busy />}{query.error && <Callout tone="error" title="文件列表加载失败" action={{ label: '重试', onClick: () => void query.refetch() }}>{query.error.message}</Callout>}
|
||||
{query.data && <div className={'manager-list' + (selected.length ? ' selection-mode' : '')}>{query.data.entries.length === 0 ? <div className="empty-state collection-empty"><Folder className="empty-glyph" aria-hidden="true" /><p>这个文件夹是空的</p><p className="muted">{query.data.writable ? '使用上方的上传或新建文件夹,整理你的书库。' : '当前目录只读,可以浏览已有文件。'}</p></div> : query.data.entries.map(entry => { const checked = selected.includes(entry.path); return <div className="manager-row" data-type={entry.type} data-selected={checked ? 'true' : undefined} key={entry.path} tabIndex={0}><button type="button" className="manager-check" role="checkbox" aria-label={`选择${entry.name}`} aria-checked={checked} onClick={event => { event.stopPropagation(); setSelected(items => items.includes(entry.path) ? items.filter(item => item !== entry.path) : [...items, entry.path]); }} /><div className="manager-name"><span className="manager-icon">{entry.type === 'dir' ? <Folder size={20} /> : <FileText size={20} />}</span><span className="manager-label">{entry.type === 'dir' ? <button type="button" className="manager-crumb" onClick={() => go(entry.path)}>{entry.name}</button> : entry.name}</span></div><div className="manager-meta muted">{entryMeta(entry)}</div><ActionMenu className="manager-more" label={`${entry.name} 的操作`} title={entry.name} items={[{ label: '重命名', disabled: !query.data.writable, onSelect: () => void rename(entry) }, { label: '选择', onSelect: () => setSelected([entry.path]) }, { label: '移动…', disabled: !query.data.writable, onSelect: () => void move([entry.path]) }, { label: '删除', danger: true, disabled: !query.data.writable, onSelect: () => void remove([entry.path]) }]} /></div>; })}</div>}
|
||||
{query.data && <div className={'manager-list' + (selected.length ? ' selection-mode' : '')}>{query.data.entries.length === 0 ? <div className="empty-state collection-empty"><Folder className="empty-glyph" aria-hidden="true" /><p>这个文件夹是空的</p><p className="muted">{query.data.writable ? '使用上方的上传或新建文件夹,整理你的书库。' : '当前目录只读,可以浏览已有文件。'}</p></div> : query.data.entries.map(entry => { const checked = selected.includes(entry.path); return <div className="manager-row" data-type={entry.type} data-selected={checked ? 'true' : undefined} key={entry.path}><button type="button" className="manager-check" role="checkbox" aria-label={`选择${entry.name}`} aria-checked={checked} onClick={event => { event.stopPropagation(); setSelected(items => items.includes(entry.path) ? items.filter(item => item !== entry.path) : [...items, entry.path]); }} /><div className="manager-name"><span className="manager-icon">{entry.type === 'dir' ? <Folder size={20} /> : <FileText size={20} />}</span><span className="manager-label">{entry.type === 'dir' ? <button type="button" className="manager-crumb" onClick={() => go(entry.path)}>{entry.name}</button> : entry.name}</span></div><div className="manager-meta muted">{entryMeta(entry)}</div><ActionMenu className="manager-more" label={`${entry.name} 的操作`} title={entry.name} items={[{ label: '重命名', disabled: !query.data.writable, onSelect: () => void rename(entry) }, { label: '选择', onSelect: () => setSelected([entry.path]) }, { label: '移动…', disabled: !query.data.writable, onSelect: () => void move([entry.path]) }, { label: '删除', danger: true, disabled: !query.data.writable, onSelect: () => void remove([entry.path]) }]} /></div>; })}</div>}
|
||||
{query.data && <div className="manager-status muted">{query.data.dirs} 个文件夹 · {query.data.files} 个文件 · {formatBytes(query.data.size)}</div>}
|
||||
{pages > 1 && <Pager className="manager-pager" page={Math.min(page, pages)} pageCount={pages} busy={query.isFetching} onGo={next => go(path, next)} />}
|
||||
</main>{dialog && <DialogView dialog={dialog} destructive="删除" onClose={answer => { const current = dialog; setDialog(null); current.resolve(answer as never); }} />}</div>;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { notify } from '../../../ui/notifications.ts';
|
||||
import { Button, Callout, IconTextButton } from '../../../ui/toolkit.tsx';
|
||||
import { SourcesHeader } from '../components/SourcesHeader.tsx';
|
||||
import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronRight, Plus, Search } from 'lucide-react';
|
||||
@@ -19,6 +19,7 @@ import { CatalogFeedback } from '../../../ui/catalog-feedback.tsx';
|
||||
import { PluginManager } from '../components/PluginManager.tsx';
|
||||
|
||||
type SourceTab = 'search' | 'sources' | 'updates' | 'plugins';
|
||||
const SOURCE_TABS: SourceTab[] = ['search', 'sources', 'updates', 'plugins'];
|
||||
type SearchState = 'idle' | 'searching' | 'stopped' | 'complete' | 'error' | 'limited';
|
||||
|
||||
export function SourcesPage() {
|
||||
@@ -27,7 +28,13 @@ export function SourcesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const admin = useAuthStore(state => state.verifiedUser?.role === 'admin');
|
||||
// /sources/plugins opens the hub on its 插件管理 tab.
|
||||
const [tab, setTab] = useState<SourceTab>(useLocation().pathname === '/sources/plugins' ? 'plugins' : 'search');
|
||||
// The tab is part of the URL (?tab=), so back from a source's editor or a reload
|
||||
// returns to it; /sources/plugins stays the plugins tab's own address.
|
||||
const [params, setParams] = useSearchParams();
|
||||
const pluginsPath = useLocation().pathname === '/sources/plugins';
|
||||
const requested = params.get('tab') as SourceTab | null;
|
||||
const tab: SourceTab = requested && SOURCE_TABS.includes(requested) ? requested : pluginsPath ? 'plugins' : 'search';
|
||||
const setTab = (next: SourceTab) => setParams(previous => { const merged = new URLSearchParams(previous); if (next === 'search' && !pluginsPath) merged.delete('tab'); else merged.set('tab', next); return merged; }, { replace: true });
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [catalogPage, setCatalogPage] = useState<SourcePage>();
|
||||
@@ -106,12 +113,22 @@ export function SourcesPage() {
|
||||
return () => { ++catalogEpoch.current; searchRun.current?.abort(); searchRun.current = null; };
|
||||
}, [selectedSourceId]);
|
||||
useEffect(() => { if (detail && detailQuery.data) setDetail({ ...detail, ...detailQuery.data }); }, [detailQuery.data]);
|
||||
const tabList = ([['search', '搜书'], ['sources', '书源管理'], ['updates', '自动追更'], ...(admin ? [['plugins', '插件管理']] : [])] as Array<[SourceTab, string]>);
|
||||
return <div className="sources-screen sources-hub">
|
||||
<SourcesHeader title="书源与追更" backTo="/shelf" />
|
||||
<nav className="sources-tabs" role="tablist" aria-label="书源功能">
|
||||
{([['search', '搜书'], ['sources', '书源管理'], ['updates', '自动追更'], ...(admin ? [['plugins', '插件管理']] : [])] as Array<[SourceTab, string]>).map(([id, label]) => <button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}>{label}{id === 'updates' && (subscriptions.data ?? []).some(item => item.newChapters > 0) && <span className="sources-tab-dot" aria-label="有更新" />}</button>)}
|
||||
{tabList.map(([id, label], index) => <button key={id} id={`sources-tab-${id}`} type="button" role="tab" aria-selected={tab === id} aria-controls="sources-panel" tabIndex={tab === id ? 0 : -1} onClick={() => setTab(id)} onKeyDown={event => {
|
||||
// Arrow keys move between tabs, one tab stop for the whole row (WAI-ARIA tabs).
|
||||
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
|
||||
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tabList.length - 1 : step ? (index + step + tabList.length) % tabList.length : -1;
|
||||
if (target < 0) return;
|
||||
event.preventDefault();
|
||||
const next = tabList[target]![0];
|
||||
setTab(next);
|
||||
document.getElementById(`sources-tab-${next}`)?.focus();
|
||||
}}>{label}{id === 'updates' && (subscriptions.data ?? []).some(item => item.newChapters > 0) && <span className="sources-tab-dot" aria-label="有更新" />}</button>)}
|
||||
</nav>
|
||||
<main className="sources-body" role="tabpanel">
|
||||
<main className="sources-body" role="tabpanel" id="sources-panel" aria-labelledby={`sources-tab-${tab}`}>
|
||||
{tab === 'search' && <>
|
||||
<section className="sources-card source-picker">
|
||||
{/* The tab already says 搜书; with no source there is nothing to pick, so no disabled picker either. */}
|
||||
|
||||
Reference in new issue
Block a user