From 77adffb1607bb23965eba2289c6f3dedc5d13ce7 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Wed, 23 Sep 2026 14:00:43 +0800 Subject: [PATCH] fix(web): keep extension tabs readable on narrow screens --- web/src/styles/reader.css | 7 +++-- web/src/ui/plugin-page-screen.tsx | 15 ++++++++-- web/tools/ui-review/host-source-flow.mjs | 37 ++++++++++++++++++++++-- 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 9bbcb28..c912275 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -3672,8 +3672,8 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin /* Generic declarative extension pages, scoped to the selected source instance. */ .extension-screen .sources-body { width: 100%; max-width: 64rem; margin-inline: auto; box-sizing: border-box; } .extension-description { color: var(--muted, #777); margin: 0 0 16px; line-height: 1.6; } -.extension-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border, #ddd); margin-bottom: 20px; } -.extension-tabs button { flex: 1; padding: 12px 8px; background: transparent; color: inherit; border: 0; border-bottom: 3px solid transparent; cursor: pointer; font: inherit; } +.extension-tabs { display: flex; flex-wrap: nowrap; gap: 4px; min-width: 0; max-width: 100%; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; border-bottom: 1px solid var(--border, #ddd); margin-bottom: 20px; } +.extension-tabs button { flex: 0 0 auto; white-space: nowrap; padding: 12px 16px; background: transparent; color: inherit; border: 0; border-bottom: 3px solid transparent; cursor: pointer; font: inherit; } .extension-tabs button[aria-selected="true"] { border-bottom-color: currentColor; font-weight: 700; } .extension-tabs button:focus-visible { outline: 2px solid currentColor; outline-offset: -3px; } .extension-screen h2 { font-size: 1rem; margin: 20px 0 12px; } @@ -3839,7 +3839,8 @@ section[aria-label="切换书源"] button { margin:.35rem .4rem .35rem 0; paddin .shelf-settings .switch-field { grid-template-columns: minmax(0, 1fr) auto; } .sources-header { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding: 2rem .9rem .75rem; border-bottom: 0; } .sources-header h1 { font-size: 1.5rem; font-weight: 650; } - .sources-hub .sources-tabs [role=tab], .extension-tabs button { flex: 0 1 auto; min-width: 9rem; padding-inline: 1.5rem; } + .sources-hub .sources-tabs [role=tab] { flex: 0 1 auto; min-width: 9rem; padding-inline: 1.5rem; } + .extension-tabs button { min-width: 7rem; padding-inline: 1.5rem; } .sources-hub .sources-body, .extension-screen .sources-body, .sources-hub .sources-tabs, .extension-toolbar { width: calc(100% - 3rem); max-width: var(--collection-measure); margin-inline: auto; padding-inline: .9rem; } .sources-body .sources-card { max-width: none; } .reader-screen { --reader-footer-height: 5.5rem; } diff --git a/web/src/ui/plugin-page-screen.tsx b/web/src/ui/plugin-page-screen.tsx index 20dedff..b7dde7e 100644 --- a/web/src/ui/plugin-page-screen.tsx +++ b/web/src/ui/plugin-page-screen.tsx @@ -46,7 +46,7 @@ export class PluginPageScreen { if (error instanceof ApiError && error.isAuthFailure) this.options.onSignedOut(); else this.error = error instanceof Error ? error.message : '操作失败'; } finally { - this.busy = false; this.draw(); + this.busy = false; this.draw(); this.revealActiveTab(); if (previousTab !== this.activeTab) { const body = this.element.querySelector('.sources-body'); if (body) body.scrollTop = 0; } if (action) (this.element.querySelector('[role=tabpanel]') ?? this.element.querySelector('.sources-body'))?.focus({ preventScroll: true }); } @@ -96,8 +96,17 @@ export class PluginPageScreen { )} ; } + private revealActiveTab() { + const list = this.element.querySelector('.extension-tabs'); + const selected = list?.querySelector('[aria-selected="true"]'); + if (!list || !selected) return; + const viewport = list.getBoundingClientRect(), item = selected.getBoundingClientRect(); + // Only move the navigation strip; scrolling ancestors would move the page. + if (item.left < viewport.left) list.scrollLeft += item.left - viewport.left; + else if (item.right > viewport.right) list.scrollLeft += item.right - viewport.right; + } private selectTab(id: string) { - this.activeTab = id; this.error = ''; this.confirmation = undefined; this.draw(); + this.activeTab = id; this.error = ''; this.confirmation = undefined; this.draw(); this.revealActiveTab(); const body = this.element.querySelector('.sources-body'); if (body) body.scrollTop = 0; } private view() { @@ -118,7 +127,7 @@ export class PluginPageScreen { else if (event.key === 'End') next = tabs.length - 1; else return; event.preventDefault(); this.selectTab(tabs[next]!.id); - document.getElementById('extension-tab-' + this.activeTab)?.focus(); + document.getElementById('extension-tab-' + this.activeTab)?.focus({ preventScroll: true }); }}>{tab.title})}} {status && } diff --git a/web/tools/ui-review/host-source-flow.mjs b/web/tools/ui-review/host-source-flow.mjs index 79212f6..b084388 100644 --- a/web/tools/ui-review/host-source-flow.mjs +++ b/web/tools/ui-review/host-source-flow.mjs @@ -141,9 +141,11 @@ try { // Deterministic extension responses exercise the shared page renderer's // busy/error/success feedback without depending on a third-party service. let releaseAction, failAction = true; - const extensionPage = { title: '扩展配置', forms: [], tabs: [{ id: 'settings', title: '设置', forms: [ + const extensionPage = { title: '扩展配置', activeTab: 'browser', forms: [], tabs: [{ id: 'settings', title: '来源设置', forms: [ { id: 'save', title: '来源设置', submit: '保存配置', fields: [{ key: 'name', label: '配置名称', type: 'text', value: '示例' }] }, - ], sections: [{ title: '配置项目', items: Array.from({ length: 12 }, (_, index) => ({ title: '项目 ' + index, description: '用于验证操作反馈不会移动内容。' })) }] }] }; + ], sections: [{ title: '配置项目', items: Array.from({ length: 12 }, (_, index) => ({ title: '项目 ' + index, description: '用于验证操作反馈不会移动内容。' })) }] }, + ...['订阅管理', '导入配置', '来源调试', '浏览器'].map((title, index) => ({ id: index === 3 ? 'browser' : 'tab-' + index, title, forms: [], description: title + '的配置内容' })), + ] }; await page.route('**/api/v1/sources/feedback-fixture/pages/settings', async route => { if (route.request().method() === 'POST') { await new Promise(resolve => { releaseAction = resolve; }); @@ -152,9 +154,40 @@ try { } return route.fulfill({ json: extensionPage }); }); + for (const width of [320, 390, 1280]) { + await page.setViewportSize({ width, height: 844 }); + await page.goto(base + '/#/sources/feedback-fixture/settings'); + await tab('浏览器').waitFor(); await idle(); + const assertActiveVisible = async () => { + const bounds = await page.locator('.extension-tabs').evaluate(list => { + const selected = list.querySelector('[aria-selected=true]').getBoundingClientRect(); + const viewport = list.getBoundingClientRect(); + return { left: selected.left, right: selected.right, start: viewport.left, end: viewport.right }; + }); + assert.ok(bounds.left >= bounds.start - 1 && bounds.right <= bounds.end + 1, 'selected tab is visible'); + assert.equal(await page.evaluate(() => window.scrollY), 0); + }; + await assertActiveVisible(); + await tab('浏览器').focus(); await page.keyboard.press('Home'); + assert.equal(await tab('来源设置').getAttribute('aria-selected'), 'true'); + await assertActiveVisible(); + await shot('extension-tabs-first-' + width); + await page.keyboard.press('End'); + assert.equal(await tab('浏览器').getAttribute('aria-selected'), 'true'); + await assertActiveVisible(); + const layout = await page.locator('.extension-tabs').evaluate(list => ({ + overflow: list.scrollWidth > list.clientWidth, + heights: [...list.children].map(item => item.getBoundingClientRect().height), + })); + assert.equal(layout.overflow, width < 600); + assert.ok(layout.heights.every(height => height >= 44 && height < 60), 'tabs stay single-line with touch-sized targets'); + await shot('extension-tabs-last-' + width); + } + delete extensionPage.activeTab; for (const width of [390, 1920]) { await page.setViewportSize({ width, height: 900 }); await page.goto(base + '/#/sources/feedback-fixture/settings'); + await tab('来源设置').click(); await button('保存配置').waitFor(); await idle(); const bodyBounds = await page.locator('.sources-body').boundingBox(); const toolbarBounds = await page.locator('.extension-toolbar').boundingBox();