From 59bb4306fc4e609e6dc27489fa9851813b93fe29 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Thu, 29 Oct 2026 03:27:45 +0000 Subject: [PATCH] =?UTF-8?q?fix(web):=20source=20row=20actions=20use=20the?= =?UTF-8?q?=20=E2=8B=AF=20dropdown=20instead=20of=20expanding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 我的来源 "管理" button and collapsible extension items (e.g. the Legado source list) expanded an inline panel that changed the row; the search options were a hand-built menu without Esc/outside-click handling. All three now use ActionMenu, as docs/ui.md requires for row actions: - 我的来源: plugin pages, 基本设置, 设为/取消默认, 暂停/启用. - Extension items: a form without fields is a menu action (confirmed first when it asks), a form with fields opens in a Sheet. - 搜索选项: 继续搜索 / 重新搜索 keep their explanation via a new MenuAction.description, exposed with aria-describedby. Source rows keep one layout at every width now that nothing expands; dead expansion and search-menu styles are removed. Co-Authored-By: Claude Opus 5.5 --- .../sources/pages/SourceExtensionPage.tsx | 35 +++++++++------ .../features/sources/pages/SourcesPage.tsx | 19 +++++--- web/src/styles/action-menu.css | 8 ++++ web/src/styles/reader.css | 43 +++++-------------- web/src/styles/sheet.css | 7 +++ web/src/ui/action-menu.tsx | 8 +++- web/tools/ui-review/host-extension-page.mjs | 4 +- web/tools/ui-review/host-source-flow.mjs | 2 +- 8 files changed, 71 insertions(+), 55 deletions(-) diff --git a/web/src/features/sources/pages/SourceExtensionPage.tsx b/web/src/features/sources/pages/SourceExtensionPage.tsx index 1a62449..de1c577 100644 --- a/web/src/features/sources/pages/SourceExtensionPage.tsx +++ b/web/src/features/sources/pages/SourceExtensionPage.tsx @@ -5,7 +5,8 @@ import type { ExtensionContent, ExtensionField, ExtensionForm, ExtensionPage } f import { ApiError } from '../../../api/errors.ts'; import { useRuntime } from '../../../app/providers/runtime-context.tsx'; import { useAuthStore } from '../../../shared/stores/auth.store.ts'; -import { ConfirmSheet } from '../../../ui/sheet.tsx'; +import { ActionMenu } from '../../../ui/action-menu.tsx'; +import { ConfirmSheet, Sheet } from '../../../ui/sheet.tsx'; import { FloatingNotice } from '../../../ui/floating-notice.tsx'; import { Button } from '../../../ui/toolkit.tsx'; import { SourcesHeader } from '../components/SourcesHeader.tsx'; @@ -71,12 +72,12 @@ function FormView({ form, formKey, values, busy, selected, onChange, onSubmit, o ; } -function ContentView({ content, scope, values, busy, selected, expanded, onChange, onSubmit, onSelectAction, onToggle, onToggleSelected }: { - content: ExtensionContent; scope: string; values: Record; busy: boolean; selected: Set; expanded: Set; +function ContentView({ content, scope, values, busy, selected, onChange, onSubmit, onSelectAction, onEdit, onToggleSelected }: { + content: ExtensionContent; scope: string; values: Record; busy: boolean; selected: Set; onChange(scope: string, key: string, value: unknown): void; onSubmit(form: ExtensionForm, values: FormValues, formKey: string): void; onSelectAction(field: ExtensionField, value: string, form: ExtensionForm, formKey: string): void; - onToggle(key: string): void; onToggleSelected(key: string): void; + onEdit(form: ExtensionForm, formKey: string, title: string): void; onToggleSelected(key: string): void; }) { const editor = <> {content.links?.filter(link => safeExternalUrl(link.url)).map(link => {link.title})} @@ -86,12 +87,18 @@ function ContentView({ content, scope, values, busy, selected, expanded, onChang {content.sections?.map((section, sectionIndex) =>

{section.title}

{!section.items.length && section.emptyText &&

{section.emptyText}

} - {section.items.map((item, itemIndex) => { const key = `${scope}:item:${sectionIndex}:${itemIndex}`; const open = expanded.has(key); return
+ {section.items.map((item, itemIndex) => { const key = `${scope}:item:${sectionIndex}:${itemIndex}`; const formKey = (form: ExtensionForm) => `${key}:form:${form.id}`; + // A collapsible item's forms are its row actions: a ⋯ menu, never an in-place expansion. + // A form with fields opens in a Sheet; a bare button form submits (confirming first if asked). + const menu = item.collapsible ? (item.forms ?? []).map(form => form.fields.length + ? { label: form.title || form.submit, disabled: busy, onSelect: () => onEdit(form, formKey(form), item.title) } + : { label: form.submit, danger: !!form.confirm, disabled: busy, onSelect: () => onSubmit(form, values[formKey(form)] ?? initialValues(form), formKey(form)) }) : []; + return
{item.selectable && item.key && onToggleSelected(item.key!)} />}

{item.title}

{item.description &&

{item.description}

} -
{item.collapsible && }
- + {item.collapsible && } + {!item.collapsible && !!item.forms?.length &&
{item.forms.map(form => onChange(formKey(form), field, value)} onSubmit={onSubmit} onSelectAction={onSelectAction} />)}
}
; })}
)} {content.outputs?.map(output =>
{output.title}
{output.text}
)} @@ -106,17 +113,16 @@ export function SourceExtensionPage() { const pageQuery = useQuery({ queryKey, queryFn: () => sourceId ? api.sourcePage(sourceId, pageId) : api.pluginPage(pluginId!, pageId), enabled: Boolean(pageId && (sourceId || admin)) }); const action = useMutation({ mutationFn: ({ id, values }: { id?: string; values?: FormValues }) => sourceId ? api.sourcePage(sourceId, pageId, id, values) : api.pluginPage(pluginId!, pageId, id, values) }); const page = pageQuery.data as ExtensionPage | undefined; const [activeTab, setActiveTab] = useState(''); - const [values, setValues] = useState>({}); const [selected, setSelected] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); + const [values, setValues] = useState>({}); const [selected, setSelected] = useState>(new Set()); const [editing, setEditing] = useState<{ form: ExtensionForm; key: string; title: string } | null>(null); const [error, setError] = useState(''); const [noticeTab, setNoticeTab] = useState(''); const [confirmation, setConfirmation] = useState<{ form: ExtensionForm; values: FormValues; key: string } | null>(null); const busy = action.isPending; const tabs = page?.tabs ?? []; const selectedTab = useMemo(() => tabs.find(tab => tab.id === activeTab), [tabs, activeTab]); - useEffect(() => { setActiveTab(''); setValues({}); setSelected(new Set()); setExpanded(new Set()); setError(''); setNoticeTab(''); setConfirmation(null); }, [owner, pageId]); + useEffect(() => { setActiveTab(''); setValues({}); setSelected(new Set()); setEditing(null); setError(''); setNoticeTab(''); setConfirmation(null); }, [owner, pageId]); useEffect(() => { if (!page) return; setActiveTab(current => page.activeTab && !current ? page.activeTab : (current && tabs.some(tab => tab.id === current) ? current : tabs[0]?.id ?? '')); setNoticeTab(current => current || page.activeTab || tabs[0]?.id || ''); }, [page, tabs]); const updateValue = (key: string, field: string, value: unknown) => setValues(previous => { const form = findForm(page, key); const base = previous[key] ?? (form ? initialValues(form) : {}); return { ...previous, [key]: { ...base, [field]: value } }; }); - const toggleExpanded = (key: string) => setExpanded(previous => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const toggleSelected = (key: string) => setSelected(previous => { const next = new Set(previous); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const execute = async (id?: string, payload?: FormValues, submittedKey?: string, submittedForm?: ExtensionForm) => { setError(''); @@ -127,6 +133,7 @@ export function SourceExtensionPage() { const next = await action.mutateAsync(request); queryClient.setQueryData(queryKey, next); if (submittedKey) setValues(previous => { const copy = { ...previous }; delete copy[submittedKey]; return copy; }); + if (submittedKey) setEditing(current => current?.key === submittedKey ? null : current); setNoticeTab(activeTab); setConfirmation(null); return true; @@ -153,9 +160,11 @@ export function SourceExtensionPage() { {tabs.length > 0 &&
{tabs.map((tab, index) => )}
} {status && }
{page.description &&

{page.description}

} - - {selectedTab &&
{selectedTab.description &&

{selectedTab.description}

}
} -
{confirmation && setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />} + setEditing({ form, key, title })} onToggleSelected={toggleSelected} /> + {selectedTab &&
{selectedTab.description &&

{selectedTab.description}

} setEditing({ form, key, title })} onToggleSelected={toggleSelected} />
} + {editing && setEditing(null)}> +
updateValue(editing.key, field, value)} onSubmit={submit} onSelectAction={selectAction} />
+
}{confirmation && setConfirmation(null)} onConfirm={() => void execute(confirmation.form.id, confirmation.values, confirmation.key, confirmation.form)} />} ; } diff --git a/web/src/features/sources/pages/SourcesPage.tsx b/web/src/features/sources/pages/SourcesPage.tsx index 5d9a827..9a2adf5 100644 --- a/web/src/features/sources/pages/SourcesPage.tsx +++ b/web/src/features/sources/pages/SourcesPage.tsx @@ -1,4 +1,5 @@ import { Sheet } from '../../../ui/sheet.tsx'; +import { ActionMenu } from '../../../ui/action-menu.tsx'; import { Button, Callout, IconTextButton } from '../../../ui/toolkit.tsx'; import { SourcesHeader } from '../components/SourcesHeader.tsx'; import { SourceBookDetailSheet } from '../components/SourceBookDetailSheet.tsx'; @@ -28,14 +29,12 @@ export function SourcesPage() { const [tab, setTab] = useState(useLocation().pathname === '/sources/plugins' ? 'plugins' : 'search'); const [sourceId, setSourceId] = useState(''); const [keyword, setKeyword] = useState(''); - const [managing, setManaging] = useState(null); const [catalogPage, setCatalogPage] = useState(); const [catalogError, setCatalogError] = useState(''); const [catalogBusy, setCatalogBusy] = useState(false); const [catalogFilters, setCatalogFilters] = useState>({}); const [detail, setDetail] = useState(null); const [searchState, setSearchState] = useState('idle'); - const [searchMenu, setSearchMenu] = useState(false); const [resultGroupKey, setResultGroupKey] = useState(null); const [resultGroupQuery, setResultGroupQuery] = useState(''); const [sourceNameQuery, setSourceNameQuery] = useState(''); @@ -81,7 +80,7 @@ export function SourcesPage() { if (!request?.query || !selectedSource || catalogBusy || (append && !canResume)) return; const run = new AbortController(); searchRun.current = run; ++catalogEpoch.current; const current = () => searchRun.current === run; - setCatalogBusy(true); setCatalogError(''); setSearchState('searching'); setSearchMenu(false); + setCatalogBusy(true); setCatalogError(''); setSearchState('searching'); if (!append) { setCatalogPage(undefined); searchSession.current = crypto.randomUUID(); setAcquired(null); setResultGroupKey(null); } searchRequest.current = request; try { @@ -97,7 +96,7 @@ export function SourcesPage() { }; const acquireEntry = (entry: SourceEntry, option?: string) => acquire.mutate(option === undefined ? { ref: entry.ref } : { ref: entry.ref, option }, { onSuccess: result => { if (result.kind === 'action-required') setCatalogError(result.action?.label ?? '请先完成授权'); else if (result.publicationId) { setAcquired({ id: result.publicationId, ref: entry.ref, title: entry.title }); void queryClient.invalidateQueries({ queryKey: ['shelf'] }); } } }); const mutateSubscription = async (id: string, enabled?: boolean) => { setBusy(id); try { if (enabled === undefined) await runtime.api.refreshPublication(id); else await runtime.api.configureSubscription(id, { enabled }); await queryClient.invalidateQueries({ queryKey: ['subscriptions'] }); } finally { setBusy(null); } }; - const updateSource = async (id: string, patch: { enabled?: boolean; isDefault?: boolean }) => { setBusy(id); try { await runtime.api.saveSource(id, patch); await sources.refetch(); setManaging(null); } finally { setBusy(null); } }; + const updateSource = async (id: string, patch: { enabled?: boolean; isDefault?: boolean }) => { setBusy(id); try { await runtime.api.saveSource(id, patch); await sources.refetch(); } finally { setBusy(null); } }; useEffect(() => { ++catalogEpoch.current; searchRun.current?.abort(); searchRun.current = null; setCatalogBusy(false); setCatalogPage(undefined); setCatalogError(''); setCatalogFilters({}); setDetail(null); searchSession.current = null; @@ -125,7 +124,10 @@ export function SourcesPage() { {selectedSource.descriptor?.capabilities.includes('search') &&
{ event.preventDefault(); void searchSources(resume); }}> {sourceFilters.data?.filter(field => field.type === 'select').map(field => setCatalogFilters(previous => { const next = { ...previous, [field.key]: value }; for (const dependent of sourceFilters.data ?? []) { if ((dependent.changeAction ?? dependent.dependsOn) === field.key && !dependent.options?.some(option => option.value === next[dependent.key] && (!option.parentValues || !value || option.parentValues.includes(value)))) next[dependent.key] = ''; } return next; })} />)} - {searchState === 'searching' ? :
{searchRequest.current && <>{searchMenu &&
}}
} + {searchState === 'searching' ? :
{searchRequest.current && void searchSources(true) }, + { label: '重新搜索', description: '清空结果,从头搜索', onSelect: () => void searchSources(false) }, + ]} />}
} } {searchState !== 'idle' &&
{({ searching: '正在搜索', stopped: '已停止搜索', complete: '搜索完成', error: '搜索中断', limited: '已达到结果上限' })[searchState]}{catalogPage?.batch ? ` · 已检查 ${catalogPage.batch.completed} / ${catalogPage.batch.total} 个来源` : ''} · 已找到 {results.length} 本书{catalogPage?.batch && }
} {catalogPage && grouped && } @@ -141,7 +143,12 @@ export function SourcesPage() { {tab === 'sources' &&

我的来源 {sources.data?.length ?? 0}

{admin && 添加来源}
{sources.isPending && }{sources.error &&

{sources.error.message}

}{!sources.isPending && !sources.data?.length &&

还没有配置书源。请添加来源或启用插件。

} - {(sources.data ?? []).map(source =>
{source.name}{source.isDefault && 默认}{source.descriptor?.label ?? sourceTypeLabel(source.sourceType)}{source.enabled ? '已启用' : '已暂停'}
{source.descriptor?.credentialKeys?.length ? 书源登录 : null}{admin && }
{admin && managing === source.id &&
{source.descriptor?.extensions?.pages?.map(page => {page.title})}基本设置
}
)} + {(sources.data ?? []).map(source =>
{source.name}{source.isDefault && 默认}{source.descriptor?.label ?? sourceTypeLabel(source.sourceType)}{source.enabled ? '已启用' : '已暂停'}
{source.descriptor?.credentialKeys?.length ? 书源登录 : null}{admin && ({ label: page.title, onSelect: () => navigate('/sources/' + encodeURIComponent(source.id) + '/' + encodeURIComponent(page.id)) })), + { label: '基本设置', onSelect: () => navigate('/sources/manage/' + encodeURIComponent(source.id)) }, + { label: source.isDefault ? '取消默认' : '设为默认', disabled: busy === source.id || (!source.isDefault && (!source.enabled || !source.descriptor?.capabilities.includes('search'))), onSelect: () => void updateSource(source.id, { isDefault: !source.isDefault }) }, + { label: source.enabled ? '暂停' : '启用', disabled: busy === source.id, onSelect: () => void updateSource(source.id, { enabled: !source.enabled }) }, + ]} />}
)}
} {tab === 'updates' &&

自动追更

服务端定时检查目录,浏览器关闭后继续运行。正文和图片在阅读时缓存。

{subscriptions.isPending && }{!subscriptions.isPending && !subscriptions.data?.length &&

先从章节书源加入书籍,再开启追更。

}{(subscriptions.data ?? []).map(item =>
{item.title}{item.newChapters ? '新增 ' + item.newChapters + ' 章 · ' : ''}上次成功:{item.lastSuccessAt ? new Date(item.lastSuccessAt).toLocaleString() : '尚未检查'}{item.enabled ? '下次检查:' + new Date(item.nextCheckAt).toLocaleString() : '自动追更已关闭'}{item.lastError ? ' · 检查失败:' + item.lastError : ''}
阅读
)}
} {tab === 'plugins' && (admin ? : 只有管理员可以管理插件)} diff --git a/web/src/styles/action-menu.css b/web/src/styles/action-menu.css index b4ff979..4d50e9c 100644 --- a/web/src/styles/action-menu.css +++ b/web/src/styles/action-menu.css @@ -88,6 +88,14 @@ text-overflow: ellipsis; } +.action-menu-description { + display: block; + color: var(--color-muted); + font-size: var(--text-meta); + line-height: 1.4; + white-space: normal; +} + .action-menu-check { flex: none; } diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 103f878..c43586d 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -3609,8 +3609,9 @@ book-content { .sources-body { overflow:auto; padding:0 1rem 2rem; flex:1; } .sources-card { max-width:64rem; margin:0 auto 1rem; padding:1rem; border:1px solid var(--reader-line); border-radius:var(--radius-lg); } .sources-card h2 { margin:.25rem 0 1rem; font-size:var(--text-title); } -.sources-card label { display:flex; flex-direction:column; gap:.4rem; margin:.75rem 0; } -.sources-card input:not([type=checkbox]), .sources-card select, .sources-card textarea { width:100%; box-sizing:border-box; padding:.65rem; border:1px solid var(--reader-line); border-radius:var(--radius-sm); background:var(--reader-surface); color:inherit; font:inherit; } +.sources-card label, .extension-sheet-form label { display:flex; flex-direction:column; gap:.4rem; margin:.75rem 0; } +.sources-card input:not([type=checkbox]), .sources-card select, .sources-card textarea, +.extension-sheet-form input:not([type=checkbox]), .extension-sheet-form select, .extension-sheet-form textarea { width:100%; box-sizing:border-box; padding:.65rem; border:1px solid var(--reader-line); border-radius:var(--radius-sm); background:var(--reader-surface); color:inherit; font:inherit; } .sources-card textarea { min-height:6rem; } .sources-row { display:flex; flex-wrap:wrap; gap:1rem; align-items:center; justify-content:space-between; padding:1rem 0; border-top:1px solid var(--reader-line); } .sources-row > div:first-child { flex:1 1 15rem; min-width:0; overflow-wrap:anywhere; } @@ -3638,12 +3639,6 @@ book-content { .search-split > .primary:first-child { border-radius:var(--reader-radius) 0 0 var(--reader-radius); } .search-split > .primary:only-child { border-radius:var(--reader-radius); } .search-split .search-menu-toggle { border-radius:0 var(--reader-radius) var(--reader-radius) 0; padding-inline:.65rem; border-left:1px solid var(--reader-line); } -.search-menu-toggle .icon { transform:rotate(90deg); } -.search-menu { position:absolute; right:0; top:calc(100% + .45rem); z-index:5; width:min(17rem, calc(100vw - 4rem)); padding:.35rem; border:1px solid var(--reader-line); border-radius:var(--radius-md); background:var(--reader-paper); box-shadow:0 8px 24px #0002; } -.search-menu button { display:flex; align-items:center; width:100%; padding:.65rem .75rem; text-align:left; border:0; background:transparent; color:var(--reader-ink); border-radius:var(--radius-sm); } -.search-menu button:focus-visible { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); } -@media (hover: hover) and (pointer: fine) { .search-menu button:hover { background:color-mix(in srgb, var(--reader-accent) 12%, transparent); }} -.search-menu small { display:block; margin-top:.2rem; color:var(--reader-muted); font-size:var(--text-meta); } .catalog-summary { display:flex; flex-wrap:wrap; gap:.5rem 1rem; justify-content:space-between; align-items:center; } .catalog-summary strong { font-size:var(--text-title); } .catalog-summary span { display:flex; align-items:center; gap:.4rem; color:var(--reader-muted); font-size:var(--text-ui); } @@ -3819,7 +3814,7 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .sources-hub .sources-tabs { flex: none; flex-wrap: nowrap; gap: 0; padding: 0 1rem; width: 100%; max-width: 66rem; margin: 0 auto 1rem; } .sources-hub .sources-tabs [role=tab] { flex: 1; min-width: 0; min-height: 52px; padding: .85rem .35rem; border: 0; border-bottom: 2px solid var(--reader-line); background: transparent; color: var(--reader-muted); font: inherit; cursor: pointer; } .sources-hub .sources-tabs [aria-selected=true] { color: var(--reader-ink); border-bottom-color: var(--reader-accent); font-weight: 650; } -.sources-hub .sources-tabs [role=tab]:focus-visible, .source-manage-trigger:focus-visible, .source-management-link:focus-visible { outline: var(--focus-ring); outline-offset: -2px; } +.sources-hub .sources-tabs [role=tab]:focus-visible { outline: var(--focus-ring); outline-offset: -2px; } .sources-tab-dot { display: inline-block; width: 6px; height: 6px; margin: 0 0 .2rem .35rem; border-radius: 50%; background: var(--reader-fill); } .sources-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .5rem; } .sources-list-heading h2 { margin: 0; } @@ -3827,20 +3822,16 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .source-add { display: inline-flex; align-items: center; gap: .3rem; min-height: 44px; padding: .5rem .7rem; } .sources-list .source-entry { display: block; padding: 1rem 0; } .sources-list .source-entry:last-child { padding-bottom: 0; } -.source-entry-top { display: flex; align-items: center; gap: 1rem; } -.source-entry-info { flex: 1; min-width: 0; } +/* Info left, actions right at every width; the actions wrap below only when the row is too narrow. */ +.source-entry-top { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem 1rem; } +.source-entry-info { flex: 1 1 12rem; min-width: 0; } .source-entry-info strong { display: block; line-height: 1.5; } .source-entry-info small { font-size: var(--text-label); color: var(--reader-muted); opacity: 1; } .source-state { display: inline-block; font-size: var(--text-meta); color: var(--reader-muted); margin-top: .5rem; } .source-state::before { content: ''; display: inline-block; height: 5px; width: 5px; border-radius: 50%; background: currentColor; vertical-align: middle; margin-right: .35rem; } .source-state.is-enabled { color: var(--reader-accent); } -.source-entry-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; } -.source-manage-trigger { display: inline-flex; align-items: center; justify-content: center; gap: .25rem; min-height: 44px; padding: .4rem; border: 0; background: transparent; font: inherit; font-size: var(--text-ui); color: var(--reader-muted); cursor: pointer; } -.source-manage-trigger[aria-expanded=true] .icon { transform: rotate(90deg); } -.source-management { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem 1rem; margin-top: .85rem; padding: .5rem .75rem; border-radius: var(--radius-md); background: color-mix(in srgb, var(--reader-ink) 4%, transparent); } -.source-management-link { display: inline-flex; align-items: center; justify-content: space-between; gap: .5rem; min-height: 44px; padding: .5rem 0; border: 0; background: transparent; color: var(--reader-ink); font: inherit; font-size: var(--text-ui); text-decoration: none; cursor: pointer; } -.source-management-link:disabled, .source-manage-trigger:disabled, .sources-hub [role=tab]:disabled { opacity: .5; cursor: default; } -.source-toggle { color: var(--reader-muted); } +.source-entry-actions { display: flex; align-items: center; gap: .6rem; flex-shrink: 0; margin-inline-start: auto; } +.sources-hub [role=tab]:disabled { opacity: .5; cursor: default; } .sources-hub .sources-tabs [role=tab] { font-size: var(--text-ui); } .source-picker label { max-width: 32rem; } .sources-search .searchable-select { flex: 1 1 12rem; min-width: 12rem; } @@ -3971,19 +3962,12 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti @media (max-width: 600px) { .sources-header { gap:.6rem; padding-inline:calc(.75rem + var(--safe-left)) calc(.75rem + var(--safe-right)); } .sources-header h1 { font-size:var(--text-title); } - .source-entry-top { align-items:flex-start; flex-direction:column; gap:.6rem; } - .source-entry-actions, .sources-actions { width:100%; flex-wrap:wrap; } - .source-entry-actions .button, .sources-actions .button { flex:1 1 8rem; } + .sources-actions { width:100%; flex-wrap:wrap; } + .sources-actions .button { flex:1 1 8rem; } .source-interval { width:100%; justify-content:space-between; } .source-interval select { flex:1; } .library-files-screen .manager-actions { overflow-x:auto; } } -@media (max-width: 600px) { - .source-entry-actions { flex-direction: column; gap: 0; } - .source-entry-top { align-items: center; } - .source-management-link { width: 100%; } - .source-management-link + .source-management-link { border-top: 1px solid var(--reader-line); } -} /* Reading source actions share the toolbar; provenance occupies its own compact row. */ .reader-screen .topbar { flex-wrap: wrap; } @@ -4124,11 +4108,6 @@ svg.icon { display: inline-block; width: 1em; height: 1em; flex-shrink: 0; verti .source-capabilities dl > div { padding: 8px; border: 1px solid currentColor; border-radius: var(--radius-sm); } .source-capabilities dd { margin: 4px 0 0; } -/* Align split search actions and source filtering controls. */ -.search-menu button { display:grid; grid-template-columns:2rem minmax(0,1fr); align-items:center; column-gap:.65rem; } -.search-menu button > .icon { width:2rem; height:2rem; display:grid; place-items:center; } -.search-menu button > span { min-width:0; line-height:1.35; } -.search-menu small { line-height:1.4; } /* The class is on the sheet's itself; a 16px font keeps iOS from zooming on focus. */ .catalog-source-search { display:block; flex:none; width:100%; min-height:44px; box-sizing:border-box; padding:.6rem .8rem; border:1px solid var(--reader-line); border-radius:var(--radius-md); background:var(--reader-paper); color:var(--reader-ink); font:inherit; font-size:var(--text-title); } .catalog-source-search:focus { border-color:var(--reader-accent); outline:2px solid color-mix(in srgb,var(--reader-accent) 25%,transparent); outline-offset:1px; } diff --git a/web/src/styles/sheet.css b/web/src/styles/sheet.css index 183736c..999882d 100644 --- a/web/src/styles/sheet.css +++ b/web/src/styles/sheet.css @@ -211,6 +211,13 @@ min-width: 0; } +.sheet-list-description { + display: block; + color: var(--color-muted); + font-size: var(--text-meta); + line-height: 1.4; +} + .sheet-list-item[aria-checked='true'] { color: var(--reader-accent); font-weight: 600; diff --git a/web/src/ui/action-menu.tsx b/web/src/ui/action-menu.tsx index bcfdb93..4fc5d2b 100644 --- a/web/src/ui/action-menu.tsx +++ b/web/src/ui/action-menu.tsx @@ -33,6 +33,8 @@ export interface MenuAction { danger?: boolean; /** A choice in a single-select menu (a filter, a sort order): marked when current. */ checked?: boolean; + /** A second, muted line under the label, for actions whose names alone are close. */ + description?: string; } /** `false`, `null` and `undefined` entries are skipped, so callers can write `admin && {…}`. */ @@ -156,15 +158,19 @@ function ActionDropdown({ id, label, anchor, actions, onClose, onPick }: { /** One row; `base` names its classes (`sheet-list-item`, `sheet-list-label`, …). */ function MenuRow({ action, base, onPick }: { action: MenuAction; base: 'action-menu' | 'sheet-list'; onPick(): void }): JSX.Element { const className = `${base}-item`; + const descriptionId = useId(); return ( ); diff --git a/web/tools/ui-review/host-extension-page.mjs b/web/tools/ui-review/host-extension-page.mjs index 761708f..ba59fe2 100644 --- a/web/tools/ui-review/host-extension-page.mjs +++ b/web/tools/ui-review/host-extension-page.mjs @@ -41,8 +41,8 @@ try { if(json!==undefined)return route.fulfill({contentType:'application/json',body:JSON.stringify(json)}); return route.continue(); }); - await page.goto(base); await page.locator('input[autocomplete="username"]').fill('review');await page.locator('input[type="password"]').fill('password12');await page.locator('button[type=submit]').click();await page.locator('.shelf-screen').waitFor(); - await page.goto(base+'/#/sources');await page.getByRole('tab',{name:'书源管理',exact:true}).click();await page.getByRole('button',{name:'管理',exact:true}).click();await page.getByRole('link',{name:'来源配置'}).click();await page.getByRole('heading',{name:'我的远程书源',exact:true}).waitFor(); + await page.goto(base); await page.locator('input[autocomplete="username"]').fill('review');await page.locator('input[type="password"]').fill('password12');await page.locator('button[type=submit]').click();await page.locator('.home-page').waitFor(); + await page.goto(base+'/#/sources');await page.getByRole('tab',{name:'书源管理',exact:true}).click();await page.getByRole('button',{name:'管理',exact:true}).click();await page.getByRole('menuitem',{name:'来源配置'}).click();await page.getByRole('heading',{name:'我的远程书源',exact:true}).waitFor(); assert.equal(new URL(page.url()).hash,'#/sources/catalog/library'); assert.equal(await page.getByRole('tab').count(),2); await page.getByLabel('配置名称').fill('修改后的配置'); diff --git a/web/tools/ui-review/host-source-flow.mjs b/web/tools/ui-review/host-source-flow.mjs index 0af79b7..2cca24d 100644 --- a/web/tools/ui-review/host-source-flow.mjs +++ b/web/tools/ui-review/host-source-flow.mjs @@ -124,7 +124,7 @@ try { await tab('书源管理').click(); const row = page.locator('.sources-row').filter({hasText:'示例章节源'}); await row.getByRole('button',{name:'管理',exact:true}).click(); - await row.getByRole('button', { name: '设为默认', exact: true }).click(); await idle(); + await page.getByRole('menuitem', { name: '设为默认', exact: true }).click(); await idle(); await row.locator('.source-default-badge').waitFor(); await shot('hub-management-mobile'); for (const width of [320,390,1280]) { await page.setViewportSize({width,height:844}); await shot('hub-'+width); }