Optimize UI display issues

Fixes #321
This commit is contained in:
lzt404 committed 2026-09-28 16:42:22 +08:00
1 parent 3cfee2451d
commit c0c79b0bb6
6 files changed
+138 -17

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
"check": "tsc --noEmit",
"preview": "vite preview",
"test:ui:accessibility": "node tests/app-shell-accessibility-ui.cjs",
"test:ui:api-provider-dialog": "node tests/api-provider-dialog-layout-ui.cjs",
"screenshots": "node scripts/capture-readme-screenshots.cjs",
"tauri": "tauri"
},
+4 -1
View File
@@ -11,6 +11,7 @@ import {
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { invoke } from '@tauri-apps/api/core';
import {
closestCenter,
@@ -2049,7 +2050,7 @@ export function ApiProviderDialog({
? t('apiAccess.models.autoHint')
: t('apiAccess.models.allHint');
return (
const content = (
<>
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !busy && onClose()}>
<form ref={providerDialogRef} className="config-dialog management-dialog api-provider-dialog" role="dialog" aria-modal="true" aria-labelledby="api-provider-dialog-title" onSubmit={(event) => void submit(event)}>
@@ -2257,4 +2258,6 @@ export function ApiProviderDialog({
) : null}
</>
);
// Keep both dialogs outside the page's query container, which can contain fixed elements in WebKit.
return typeof document === 'undefined' ? content : createPortal(content, document.body);
}
+7 -8
View File
@@ -5108,17 +5108,16 @@ textarea:focus-visible {
min-height: 360px;
}
.real-provider-workbench + .config-dialog-backdrop .management-dialog {
grid-template-rows: auto repeat(5, minmax(52px, auto)) auto;
height: auto;
max-height: min(720px, calc(100vh - 48px));
overflow: auto;
}
.real-provider-workbench + .config-dialog-backdrop .api-provider-dialog {
.config-dialog.api-provider-dialog {
grid-template-rows: auto;
grid-auto-rows: auto;
width: min(560px, 100%);
height: auto;
min-height: 0;
max-height: min(720px, calc(100vh - 48px));
max-height: min(720px, calc(100dvh - 48px));
overflow: auto;
overscroll-behavior: contain;
gap: 10px;
padding: 16px 18px;
scrollbar-gutter: stable;
+115
View File
@@ -0,0 +1,115 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
(async () => {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
const server = await createServer({
configFile: false,
root: path.resolve(__dirname, '..'),
plugins: [react()],
optimizeDeps: { entries: ['tests/fixtures/provider-duplicates.html'] },
logLevel: 'error',
server: { host: '127.0.0.1', port: 1421, strictPort: false, watch: null },
});
let browser;
try {
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
await (await fetch(base)).text();
const channel = process.env.PLAYWRIGHT_CHANNEL || (process.platform === 'win32' ? 'msedge' : undefined);
browser = await chromium.launch({ channel, headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(String(error)));
await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
await page.goto(`${base}/tests/fixtures/provider-duplicates.html`, { waitUntil: 'domcontentloaded' });
await page.locator('.provider-category-panel button').filter({ hasText: 'Claude' }).click();
await page.locator('.real-provider-row').waitFor();
await page.evaluate(() => {
window.providerFixture.records[0].models = Array.from({ length: 30 }, (_, index) => ({ name: `model-${index}` }));
});
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
await page.locator('.real-provider-row').getByRole('button', { name: 'Edit', exact: true }).click();
const form = page.getByRole('dialog', { name: 'Edit API Connection', exact: true });
assert.equal(await form.locator('.model-config-entry').count(), 30);
// Older WebKit makes an inline-size query container a fixed-position containing block.
// Explicit layout containment reproduces that behavior on current browser engines.
await page.addStyleTag({ content: '.content { contain: layout; min-height: 1400px; }' });
const assertViewportOverlay = async (selector) => {
const bounds = await page.locator(selector).evaluate((element) => {
const rect = element.getBoundingClientRect();
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
});
const viewport = page.viewportSize();
assert.deepEqual(bounds, { x: 0, y: 0, ...viewport }, `${selector} must cover the viewport independently of page containment`);
};
const assertDialogControls = async (dialog) => {
const bounds = await dialog.boundingBox();
const viewport = page.viewportSize();
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width + 1
&& bounds.y + bounds.height <= viewport.height + 1, 'Dialog must fit inside the window');
for (const button of ['Close', 'Cancel', 'Save']) {
const control = dialog.getByRole('button', { name: button, exact: true });
const visible = await control.evaluate((element) => {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.bottom <= innerHeight
&& element.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2));
});
assert.equal(visible, true, `${button} must remain visible and unobstructed`);
}
const dimensions = await dialog.evaluate((element) => ({ client: element.clientWidth, scroll: element.scrollWidth }));
assert.ok(dimensions.scroll <= dimensions.client + 1, 'Dialog must not overflow horizontally');
};
for (const viewport of [{ width: 1280, height: 800 }, { width: 1024, height: 600 }, { width: 640, height: 600 }]) {
await page.setViewportSize(viewport);
await assertViewportOverlay('.config-dialog-backdrop');
await assertDialogControls(form);
await form.getByLabel('Remark', { exact: true }).fill('Small-window edit');
await form.locator('.provider-advanced-settings summary').click();
const headers = form.getByLabel(/Custom Headers/);
await headers.fill('X-Layout-Test: reachable');
await assertDialogControls(form);
const scroll = await form.evaluate((element) => ({ top: element.scrollTop, max: element.scrollHeight - element.clientHeight }));
assert.ok(scroll.top > 0 && scroll.max > 0, 'Advanced settings must be reachable by scrolling inside the dialog');
await form.locator('.provider-advanced-settings summary').click();
}
await form.getByRole('button', { name: 'Fetch Models', exact: true }).click();
const models = page.getByRole('dialog', { name: 'Select Models', exact: true });
await models.waitFor();
await assertViewportOverlay('.model-discovery-backdrop');
for (let index = 0; index < 5; index += 1) {
await page.keyboard.press('Tab');
assert.equal(await models.evaluate((element) => element.contains(document.activeElement)), true);
}
await page.keyboard.press('Escape');
await models.waitFor({ state: 'detached' });
assert.equal(await form.getByRole('button', { name: 'Fetch Models', exact: true }).evaluate((element) => element === document.activeElement), true);
await assertDialogControls(form);
await form.getByRole('button', { name: 'Save', exact: true }).click();
await form.waitFor({ state: 'detached' });
const record = await page.evaluate(() => window.providerFixture.records[0]);
assert.equal(record.headers['X-Layout-Test'], 'reachable');
assert.equal(record.models.length, 30);
assert.equal(await page.evaluate(() => document.body.style.overflow), '');
await page.locator('.provider-category-panel button').filter({ hasText: 'OpenAI' }).click();
await page.getByRole('button', { name: 'Add', exact: true }).click();
const add = page.getByRole('dialog', { name: 'Add API Connection', exact: true });
await assertViewportOverlay('.config-dialog-backdrop');
await assertDialogControls(add);
await add.getByRole('button', { name: 'Cancel', exact: true }).click();
await add.waitFor({ state: 'detached' });
assert.deepEqual(errors, []);
console.log('PASS: #321 provider dialogs fit small windows, escape page containment, scroll long forms, preserve nested focus, and save edits.');
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+10 -7
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { ApiAccessPage, providerRemarkIdentity, type ApiAccessRemarkLocator, type ProviderSection } from '../../src/pages/ApiAccessPage';
import { flattenV8ProviderGroups, groupLegacyProviderRecords } from '../../src/services/managementApi';
import '../../src/styles/index.css';
localStorage.setItem('easy-cli-proxy-api.locale', 'en');
@@ -35,22 +36,24 @@ mockIPC((cmd, rawArgs) => {
if (cmd === 'management_request') {
const { request } = rawArgs as { request: { method: string; path: string; body: Record<string, unknown>[] } };
const section = request.path.slice(1);
if (request.method === 'POST' && section === 'requests/api-call') {
return { status_code: 200, body: { data: [{ id: 'discovered-model' }] } };
}
if (request.method === 'GET') {
if (section === 'config') return { 'claude-api-key': structuredClone(fixture.providerFixture.records) };
return { [section]: section === 'claude-api-key'
? fixture.providerFixture.records.map((record, index) => ({ ...record, 'auth-index': `auth-${index}` }))
: [] };
const groups = groupLegacyProviderRecords('claude', fixture.providerFixture.records);
if (section === 'config') return { 'api-keys': { claude: groups } };
return section === 'config/api-keys/claude' ? groups : [];
}
fixture.providerFixture.writes.push({ method: request.method, body: structuredClone(request.body) });
if (request.method !== 'PUT' || section !== 'claude-api-key') throw new Error('Unexpected provider mutation');
fixture.providerFixture.records = structuredClone(request.body);
if (request.method !== 'PUT' || section !== 'config/api-keys/claude') throw new Error('Unexpected provider mutation');
fixture.providerFixture.records = flattenV8ProviderGroups('claude', structuredClone(request.body));
return { status: 'ok' };
}
throw new Error(`Unhandled fixture command: ${cmd}`);
});
createRoot(document.getElementById('root')!).render(
<I18nProvider><div className="app-shell"><div className="workspace"><main className="content">
<I18nProvider><div className="app-shell"><aside className="sidebar" /><div className="workspace"><main className="content">
<ApiAccessPage />
</main></div></div></I18nProvider>,
);
+1 -1
View File
@@ -185,7 +185,7 @@ const { mkdirSync } = require('node:fs');
for (const [locale, theme, width, height] of [['zh-CN', 'light', 1280, 820], ['en', 'dark', 1280, 820], ['ja', 'light', 390, 740]]) {
await page.setViewportSize({ width, height });
await page.goto(`http://127.0.0.1:1423/tests/fixtures/model-selection.html?locale=${locale}&theme=${theme}`);
await page.locator('.model-config-heading button').press('Enter');
await page.locator('.model-config-heading button').click();
await ready();
const popup = page.locator('.model-transfer-dialog');
const rect = await popup.boundingBox();