- {/* Header bar */}
-
-
-
-
+ {
+ if (!open) close();
+ }}
+ >
+
+
+ {
+ event.preventDefault();
+ nameRef.current?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ if (returnFocusRef.current?.isConnected) returnFocusRef.current.focus();
+ }}
+ onEscapeKeyDown={(event) => {
+ if (savingRef.current || event.isComposing || event.keyCode === 229)
+ event.preventDefault();
+ }}
+ onPointerDownOutside={(event) => {
+ if (savingRef.current) event.preventDefault();
+ }}
+ >
+
+
+
{editing ? t('settings.mcp.dialog.editTitle') : t('settings.mcp.dialog.createTitle')}
-
-
-
-
-
- {/* Body */}
-
-
- {t('settings.mcp.dialog.desc')}
-
-
- {/* 显示名称 */}
-
- {t('settings.mcp.fields.name')}
-
+
-
- {/* transport 分段 */}
-
-
{t('settings.mcp.fields.transport')}
-
+
+ {t('settings.mcp.dialog.desc')}
+
+
- {MCP_TRANSPORTS.map((tp) => {
- const active = transport === tp;
- return (
-
- );
- })}
-
-
-
-
- {/* 端点 URL */}
-
- {t('settings.mcp.fields.url')}
-
-
-
- {/* bearer token(可选) */}
-
-
-
{t('settings.mcp.fields.token')}
- {hasToken && (
-
(
+ {
+ setName(value);
+ setErrors((prev) => ({ ...prev, name: undefined }));
+ }}
+ placeholder={t('settings.mcp.fields.namePlaceholder')}
+ />
+ )}
+
+
+
+
+ {(control) => (
+ {
+ setUrl(value);
+ setErrors((prev) => ({ ...prev, url: undefined }));
}}
- >
-
- {t('settings.mcp.fields.tokenSaved')}
-
+ placeholder={t('settings.mcp.fields.urlPlaceholder')}
+ />
)}
-
- setShowToken((v) => !v)}
- className="absolute right-[12px] top-1/2 -translate-y-1/2 text-[var(--settings-eye-icon)] transition-colors hover:text-[var(--settings-eye-icon-hover)]"
- aria-label={showToken ? t('settings.apiKey.hideKey') : t('settings.apiKey.showKey')}
- >
- {showToken ? : }
-
+
+
+
+ {t('settings.mcp.fields.tokenSaved')}
+
+ )
}
- />
-
- {t('settings.mcp.fields.tokenHelp')}
-
-
-
- {/* 请求头(可选) */}
-
-
{t('settings.mcp.fields.headers')}
- {headers.map((h, i) => (
-
-
-
- setHeaders((prev) => prev.map((y, j) => (j === i ? { ...y, name: v } : y)))
- }
- placeholder={t('settings.mcp.fields.headerNamePlaceholder')}
- />
-
-
-
- setHeaders((prev) => prev.map((y, j) => (j === i ? { ...y, value: v } : y)))
- }
- placeholder={t('settings.mcp.fields.headerValuePlaceholder')}
- />
-
-
-
- ))}
-
+ {(control) => (
+
+ )}
+
+
-
-
- {/* Footer */}
-
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/apps/desktop/src/renderer/components/settings/McpServersSection.tsx b/apps/desktop/src/renderer/components/settings/McpServersSection.tsx
index 3e94ae47851..471785d94f3 100644
--- a/apps/desktop/src/renderer/components/settings/McpServersSection.tsx
+++ b/apps/desktop/src/renderer/components/settings/McpServersSection.tsx
@@ -155,6 +155,7 @@ export function McpServersSection() {
const handleDelete = useCallback(
async (config: CustomMcpConfig) => {
const ok = await confirm({
+ presentation: 'standard',
title: t('settings.mcp.deleteConfirm.title', { name: config.name }),
description: t('settings.mcp.deleteConfirm.message'),
confirmText: t('settings.mcp.deleteConfirm.confirm'),
diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx
index 802fe5a596b..e29ec257d7f 100644
--- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx
+++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx
@@ -2446,6 +2446,7 @@ export function ProvidersSection() {
const handleDelete = useCallback(
async (p: ProviderView) => {
const ok = await confirm({
+ presentation: 'standard',
title: t('settings.providers.custom.deleteConfirm.title'),
description: t('settings.providers.custom.deleteConfirm.description', { name: p.name }),
confirmText: t('settings.providers.custom.deleteConfirm.confirm'),
diff --git a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx
index ebb79cb9b8b..f4bb84c0de0 100644
--- a/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx
+++ b/apps/desktop/src/renderer/components/settings/__tests__/CustomProviderDialogAccessibility.test.tsx
@@ -252,6 +252,9 @@ describe('CustomProviderDialog accessibility', () => {
name: 'settings.providers.custom.fields.modelContextWindowTitle',
});
await waitFor(() => expect(document.activeElement).toBe(contextWindow));
+ expect(contextWindow).toBe(
+ screen.getByPlaceholderText('settings.providers.custom.fields.modelContextWindowPlaceholder'),
+ );
});
it('cancels a pending manual create without discarding the Provider draft', async () => {
@@ -1377,3 +1380,187 @@ describe('CustomProviderDialog accessibility', () => {
expect(capability.checked).toBe(true);
});
});
+
+describe('DS-6 field errors and save ownership', () => {
+ it('reports name validation on the field and focuses it', async () => {
+ render(
);
+ await waitForInitialDialogFocus();
+ fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' }));
+ const name = screen.getByLabelText('settings.providers.custom.fields.name');
+ expect(document.activeElement).toBe(name);
+ expect(name.getAttribute('aria-invalid')).toBe('true');
+ expect(document.getElementById(name.getAttribute('aria-describedby')!)?.textContent).toBe(
+ 'settings.providers.custom.errors.nameRequired',
+ );
+ expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled();
+ });
+
+ it('locks only an actual save request and restores Cancel after failure', async () => {
+ let reject!: (error: Error) => void;
+ customProviderMocks.readCustomProviderKey.mockResolvedValue(null);
+ customProviderMocks.updateCustomProvider.mockReturnValueOnce(
+ new Promise((_, fail) => {
+ reject = fail;
+ }),
+ );
+ const onClose = vi.fn(),
+ onSaved = vi.fn();
+ render(
+
,
+ );
+ await waitForInitialDialogFocus();
+ await act(async () => {});
+ const save = screen.getByRole('button', { name: 'settings.providers.custom.save' });
+ const cancel = screen.getByRole('button', { name: 'settings.providers.custom.cancel' });
+ fireEvent.click(save);
+ fireEvent.click(save);
+ fireEvent.click(cancel);
+ fireEvent.keyDown(window, { key: 'Escape' });
+ expect(customProviderMocks.updateCustomProvider).toHaveBeenCalledOnce();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(save.getAttribute('aria-busy')).toBe('true');
+ await act(async () => reject(new Error('Try again')));
+ expect((cancel as HTMLButtonElement).disabled).toBe(false);
+ fireEvent.click(save);
+ await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
+ expect(customProviderMocks.updateCustomProvider).toHaveBeenCalledTimes(2);
+ });
+
+ it('raises secret eye and remove-row tooltips above the z-10000 modal overlay', async () => {
+ customProviderMocks.readCustomProviderKey.mockResolvedValue(null);
+ const user = userEvent.setup();
+ render(
+
,
+ );
+ await waitForInitialDialogFocus();
+
+ // Tip 经 Portal 渲染到 body,默认 z-[60] 会被 z-[10000] 模态层盖住;
+ // 弹窗内必须把提示抬到 z-[10001](review P2)。Radix Tooltip 1.2 的
+ // role="tooltip" 挂在 Content 内的 sr-only 副本上,带 z class 的可见层
+ // 是它的父节点(Popper.Content)。
+ const eye = screen.getByRole('button', { name: 'settings.apiKey.showKey' });
+ await user.hover(eye);
+ const eyeTip = await screen.findByRole('tooltip');
+ expect(eyeTip.textContent).toBe('settings.apiKey.showKey');
+ expect(eyeTip.parentElement!.className).toContain('z-[10001]');
+ // 模态 open 时 Radix 会把 body 置 pointer-events:none,userEvent.unhover 的
+ // 交互前检查会拒绝;直接派发 pointerleave 关闭提示(Radix 监听 pointer 事件)。
+ fireEvent.pointerLeave(eye);
+ await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull());
+
+ const remove = screen.getAllByRole('button', {
+ name: 'settings.providers.custom.fields.removeRow',
+ })[0]!;
+ await user.hover(remove);
+ const removeTip = await screen.findByRole('tooltip');
+ expect(removeTip.textContent).toBe('settings.providers.custom.fields.removeRow');
+ expect(removeTip.parentElement!.className).toContain('z-[10001]');
+ });
+
+ it('keeps the field error while other fields change and clears it when the errored field is edited', async () => {
+ render(
);
+ await waitForInitialDialogFocus();
+ const name = screen.getByLabelText('settings.providers.custom.fields.name');
+ const baseUrl = screen.getByLabelText('settings.providers.custom.fields.baseUrl');
+ fireEvent.change(name, { target: { value: 'X' } });
+ fireEvent.change(baseUrl, { target: { value: 'not-a-url' } });
+ fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' }));
+
+ // 非法 URL 报错落在 baseUrl,首错聚焦并带 aria-invalid。
+ expect(document.activeElement).toBe(baseUrl);
+ expect(baseUrl.getAttribute('aria-invalid')).toBe('true');
+ expect(
+ document.getElementById(baseUrl.getAttribute('aria-describedby')!)?.textContent,
+ ).toBe('settings.providers.custom.errors.baseUrlInvalid');
+
+ // 编辑其它字段(name)不得清掉 baseUrl 的错误——面板级 onChangeCapture 只在
+ // 报错字段自身被编辑时清除(review P2)。
+ fireEvent.change(name, { target: { value: 'XY' } });
+ expect(baseUrl.getAttribute('aria-invalid')).toBe('true');
+ expect(
+ document.getElementById(baseUrl.getAttribute('aria-describedby')!)?.textContent,
+ ).toBe('settings.providers.custom.errors.baseUrlInvalid');
+
+ // 编辑报错字段本身:错误清除,等下次保存重新校验。
+ fireEvent.change(baseUrl, { target: { value: 'https://example.test/v1' } });
+ expect(baseUrl.getAttribute('aria-invalid')).not.toBe('true');
+ expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled();
+ });
+
+ it('clears the list-level model error when the new model row is filled after adding it back', async () => {
+ render(
);
+ await waitForInitialDialogFocus();
+ // 名称 / baseUrl 合法,删掉仅有的空模型行 → 保存报列表级错误(渲染在「添加模型」旁,
+ // 不依赖任何行存在)。
+ fireEvent.change(screen.getByLabelText('settings.providers.custom.fields.name'), {
+ target: { value: 'X' },
+ });
+ fireEvent.change(screen.getByLabelText('settings.providers.custom.fields.baseUrl'), {
+ target: { value: 'https://example.test/v1' },
+ });
+ fireEvent.click(
+ screen.getAllByRole('button', {
+ name: 'settings.providers.custom.fields.removeRow',
+ })[0]!,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' }));
+ expect(screen.getByText('settings.providers.custom.errors.modelRequired')).toBeTruthy();
+
+ // 重新添加模型并填写:新行输入的 change 就是对列表级错误的修正,提示须同步清除
+ // ——否则会滞留到再次保存(review P1)。
+ fireEvent.click(
+ screen.getByRole('button', { name: 'settings.providers.custom.fields.addModel' }),
+ );
+ fireEvent.change(
+ screen.getByPlaceholderText('settings.providers.custom.fields.modelIdPlaceholder'),
+ { target: { value: 'm1' } },
+ );
+ expect(screen.queryByText('settings.providers.custom.errors.modelRequired')).toBeNull();
+ expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled();
+ });
+
+ it('clears any field error when a preset programmatically replaces the form values', async () => {
+ window.electronAPI.maker.listProviderPresets = vi.fn(async () => ({
+ presets: [
+ {
+ id: 'preset-a',
+ name: 'Preset A',
+ runtimes: {
+ 'claude-code': {
+ baseUrl: 'https://preset.example.test/v1',
+ models: [{ id: 'pm-1', name: 'PM 1' }],
+ },
+ },
+ },
+ ],
+ }));
+ render(
);
+ await waitForInitialDialogFocus();
+ // 保存空表单 → 名称必填报错。
+ fireEvent.click(screen.getByRole('button', { name: 'settings.providers.custom.save' }));
+ expect(screen.getByText('settings.providers.custom.errors.nameRequired')).toBeTruthy();
+
+ // 应用预设:程序化替换名称/鉴权/全部 runtime,不触发任何输入的 change——
+ // 既有字段错误的指向已整体失效,须同步清除(review P1)。
+ fireEvent.click(
+ screen.getByRole('button', { name: 'settings.providers.custom.presets.label' }),
+ );
+ fireEvent.click(screen.getByRole('option', { name: 'Preset A' }));
+ await waitFor(() =>
+ expect(screen.queryByText('settings.providers.custom.errors.nameRequired')).toBeNull(),
+ );
+ expect(
+ (screen.getByLabelText('settings.providers.custom.fields.name') as HTMLInputElement)
+ .value,
+ ).toBe('Preset A');
+ expect(customProviderMocks.createCustomProvider).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx
new file mode 100644
index 00000000000..e2b925fe015
--- /dev/null
+++ b/apps/desktop/src/renderer/components/settings/__tests__/McpServerDialog.test.tsx
@@ -0,0 +1,169 @@
+// @vitest-environment jsdom
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { McpServerDialog } from '../McpServerDialog';
+
+const api = vi.hoisted(() => ({ create: vi.fn(), update: vi.fn(), read: vi.fn(), error: vi.fn() }));
+vi.mock('@/lib/customMcpServers', () => ({
+ createCustomMcpServer: api.create,
+ updateCustomMcpServer: api.update,
+ readCustomMcpToken: api.read,
+}));
+vi.mock('@/lib/toast', () => ({ toast: { success: vi.fn(), error: api.error } }));
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+const initial = {
+ id: 'custom_original',
+ name: 'Example',
+ transport: 'sse' as const,
+ url: 'https://example.test/mcp',
+ headers: { 'X-Test': 'value' },
+};
+const field = (name: string) =>
+ screen.getByLabelText(`settings.mcp.fields.${name}`) as HTMLInputElement;
+const save = () => screen.getByRole('button', { name: 'settings.mcp.save' });
+const cancel = () => screen.getByRole('button', { name: 'settings.mcp.cancel' });
+const change = (name: string, value: string) =>
+ fireEvent.change(field(name), { target: { value } });
+beforeEach(() => {
+ vi.clearAllMocks();
+ api.create.mockResolvedValue(undefined);
+ api.update.mockResolvedValue(undefined);
+ api.read.mockResolvedValue(null);
+});
+afterEach(() => {
+ cleanup();
+ document.querySelector('[data-ds6-trigger]')?.remove();
+});
+
+it('associates field errors and focuses the first invalid field without sending a request', async () => {
+ render(
);
+ fireEvent.click(save());
+ expect(document.activeElement).toBe(field('name'));
+ for (const name of ['name', 'url']) {
+ expect(field(name).getAttribute('aria-invalid')).toBe('true');
+ expect(
+ document.getElementById(field(name).getAttribute('aria-describedby')!)?.textContent,
+ ).toContain('settings.mcp.errors.');
+ }
+ expect(api.create).not.toHaveBeenCalled();
+ change('name', 'Example');
+ change('url', 'file:///tmp/example');
+ fireEvent.click(save());
+ expect(document.activeElement).toBe(field('url'));
+ expect(api.error).not.toHaveBeenCalled();
+});
+
+it('preserves create ID, transport, trimmed headers and raw token arguments', async () => {
+ const onSaved = vi.fn();
+ render(
);
+ change('name', ' Example ');
+ change('url', ' https://example.test/mcp ');
+ change('token', ' token ');
+ // transport 走共享分段控件(review P2):role=radio 的 radiogroup,不再是独立 button。
+ expect(screen.getByRole('radiogroup', { name: 'settings.mcp.fields.transport' })).toBeTruthy();
+ fireEvent.click(screen.getByRole('radio', { name: 'sse' }));
+ fireEvent.change(screen.getByPlaceholderText('settings.mcp.fields.headerNamePlaceholder'), {
+ target: { value: ' X-Test ' },
+ });
+ fireEvent.change(screen.getByPlaceholderText('settings.mcp.fields.headerValuePlaceholder'), {
+ target: { value: ' value ' },
+ });
+ fireEvent.click(save());
+ await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
+ expect(api.create).toHaveBeenCalledWith({ ...initial, id: 'custom_example-2' }, ' token ');
+});
+
+it('blocks duplicate submit, Cancel, Escape and outside only while saving, then recovers after failure', async () => {
+ let fail!: (error: Error) => void;
+ api.update.mockReturnValueOnce(
+ new Promise((_, reject) => {
+ fail = reject;
+ }),
+ );
+ const onClose = vi.fn(),
+ onSaved = vi.fn();
+ render(
);
+ fireEvent.click(save());
+ fireEvent.click(save());
+ fireEvent.click(cancel());
+ fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' });
+ fireEvent.pointerDown(screen.getByRole('dialog').previousElementSibling!);
+ expect(api.update).toHaveBeenCalledOnce();
+ expect(onClose).not.toHaveBeenCalled();
+ expect(save().getAttribute('aria-busy')).toBe('true');
+ expect((cancel() as HTMLButtonElement).disabled).toBe(true);
+ await act(async () => fail(new Error('Unavailable')));
+ expect(api.error).toHaveBeenCalled();
+ expect(field('name').value).toBe('Example');
+ expect((cancel() as HTMLButtonElement).disabled).toBe(false);
+ fireEvent.click(save());
+ await waitFor(() => expect(onSaved).toHaveBeenCalledOnce());
+ expect(api.update).toHaveBeenCalledTimes(2);
+});
+
+it('retains existing token when saved before hydration and clears only after a loaded token is emptied', async () => {
+ let hydrate!: (value: string) => void;
+ api.read.mockReturnValue(
+ new Promise
((resolve) => {
+ hydrate = resolve;
+ }),
+ );
+ api.update.mockRejectedValueOnce(new Error('Retry'));
+ render();
+ fireEvent.click(save());
+ await waitFor(() => expect(api.error).toHaveBeenCalled());
+ expect(api.update).toHaveBeenLastCalledWith(initial, '', false);
+ await act(async () => hydrate('example-secret'));
+ expect(field('token').type).toBe('password');
+ fireEvent.click(screen.getByRole('button', { name: 'settings.apiKey.showKey' }));
+ expect(field('token').type).toBe('text');
+ change('token', '');
+ fireEvent.click(save());
+ await waitFor(() => expect(api.update).toHaveBeenLastCalledWith(initial, '', true));
+});
+
+it('keeps header IDs through row deletion and restores the opening control focus on close', async () => {
+ const user = userEvent.setup();
+ const trigger = document.createElement('button');
+ trigger.dataset.ds6Trigger = 'true';
+ document.body.append(trigger);
+ trigger.focus();
+ const { unmount } = render();
+ await waitFor(() => expect(document.activeElement).toBe(field('name')));
+ fireEvent.click(screen.getByRole('button', { name: 'settings.mcp.fields.addHeader' }));
+ const survivor = screen.getAllByPlaceholderText('settings.mcp.fields.headerNamePlaceholder')[1];
+ const id = survivor.id;
+ await user.click(screen.getByRole('button', { name: 'settings.mcp.fields.removeRow 1' }));
+ expect(screen.getByPlaceholderText('settings.mcp.fields.headerNamePlaceholder').id).toBe(id);
+ await waitFor(() => expect(document.activeElement).toBe(survivor));
+ unmount();
+ await waitFor(() => expect(document.activeElement).toBe(trigger));
+ trigger.remove();
+});
+
+it('raises secret eye and remove-row tooltips above the z-10000 modal overlay', async () => {
+ const user = userEvent.setup();
+ render();
+ await waitFor(() => expect(document.activeElement).toBe(field('name')));
+
+ // Tip 经 Portal 渲染到 body,默认 z-[60] 会被 z-[10000] 模态层盖住;
+ // 弹窗内必须把提示抬到 z-[10001](review P2)。Radix Tooltip 1.2 的
+ // role="tooltip" 挂在 Content 内的 sr-only 副本上,带 z class 的可见层
+ // 是它的父节点(Popper.Content)。
+ const eye = screen.getByRole('button', { name: 'settings.apiKey.showKey' });
+ await user.hover(eye);
+ const eyeTip = await screen.findByRole('tooltip');
+ expect(eyeTip.textContent).toBe('settings.apiKey.showKey');
+ expect(eyeTip.parentElement!.className).toContain('z-[10001]');
+ // 模态 open 时 Radix 会把 body 置 pointer-events:none,userEvent.unhover 的
+ // 交互前检查会拒绝;直接派发 pointerleave 关闭提示(Radix 监听 pointer 事件)。
+ fireEvent.pointerLeave(eye);
+ await waitFor(() => expect(screen.queryByRole('tooltip')).toBeNull());
+
+ fireEvent.click(screen.getByRole('button', { name: 'settings.mcp.fields.addHeader' }));
+ await user.hover(screen.getByRole('button', { name: 'settings.mcp.fields.removeRow 1' }));
+ const removeTip = await screen.findByRole('tooltip');
+ expect(removeTip.textContent).toBe('settings.mcp.fields.removeRow');
+ expect(removeTip.parentElement!.className).toContain('z-[10001]');
+});
diff --git a/apps/desktop/src/renderer/components/ui/__tests__/formControls.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/formControls.test.tsx
new file mode 100644
index 00000000000..6763f494faf
--- /dev/null
+++ b/apps/desktop/src/renderer/components/ui/__tests__/formControls.test.tsx
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, expect, it, vi } from 'vitest';
+import { Button } from '../button';
+import { Input } from '../input';
+import { ConfirmDialog } from '../confirm-dialog';
+
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+vi.mock('@/lib/scrollbarAutoHide', () => ({ flashScrollbar: vi.fn() }));
+afterEach(cleanup);
+
+it('retains the busy button name, blocks activation and restores it on failure', () => {
+ const click = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+ const button = screen.getByRole('button', { name: 'Save changes' });
+ fireEvent.click(button);
+ expect(click).not.toHaveBeenCalled();
+ expect(button.getAttribute('aria-busy')).toBe('true');
+ expect(button.querySelector('.motion-reduce\\:animate-none')).not.toBeNull();
+ rerender();
+ fireEvent.click(button);
+ expect(click).toHaveBeenCalledOnce();
+});
+
+it('keeps disabled secret values masked and the reveal control unavailable', () => {
+ render( {}} />);
+ const input = screen.getByLabelText('Token') as HTMLInputElement;
+ const reveal = screen.getByRole('button') as HTMLButtonElement;
+ expect(reveal.disabled).toBe(true);
+ fireEvent.click(reveal);
+ expect(input.type).toBe('password');
+ expect(reveal.getAttribute('aria-pressed')).toBe('false');
+});
+
+it.each([undefined, 'standard'] as const)(
+ 'preserves ordinary confirm ordering and Cancel focus (%s)',
+ async (presentation) => {
+ const onTertiary = vi.fn();
+ render(
+ ,
+ );
+ expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
+ 'Save',
+ 'Discard',
+ 'Cancel',
+ ]);
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Cancel' })),
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
+ expect(onTertiary).toHaveBeenCalledOnce();
+ },
+);
+
+it('prioritizes typed confirmation over explicit main focus and blocks busy closure', async () => {
+ const close = vi.fn();
+ const props = {
+ presentation: 'standard' as const,
+ open: true,
+ title: 'Delete?',
+ confirmText: 'Delete',
+ autoFocusConfirm: true,
+ requireTypedConfirmation: { expected: 'DELETE', label: 'Type DELETE' },
+ onOpenChange: close,
+ };
+ const { rerender } = render();
+ const input = screen.getByRole('textbox');
+ await waitFor(() => expect(document.activeElement).toBe(input));
+ expect((screen.getByRole('button', { name: 'Delete' }) as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.change(input, { target: { value: 'DELETE' } });
+ expect((screen.getByRole('button', { name: 'Delete' }) as HTMLButtonElement).disabled).toBe(
+ false,
+ );
+ rerender();
+ fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' });
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ expect(close).not.toHaveBeenCalled();
+});
diff --git a/apps/desktop/src/renderer/components/ui/__tests__/formField.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/formField.test.tsx
new file mode 100644
index 00000000000..977651af64d
--- /dev/null
+++ b/apps/desktop/src/renderer/components/ui/__tests__/formField.test.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+import { cleanup, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, expect, it, vi } from 'vitest';
+import { FormField } from '../form-field';
+import { Input } from '../input';
+
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+afterEach(cleanup);
+
+it('associates labels, hints, errors and caller descriptions without native required validation', async () => {
+ const { rerender } = render(
+
+ {(control) => {}} />}
+ ,
+ );
+ const input = screen.getByLabelText('Endpoint');
+ const id = input.id;
+ await userEvent.click(screen.getByText('Endpoint'));
+ expect(document.activeElement).toBe(input);
+ expect(input.getAttribute('aria-required')).toBe('true');
+ expect(input.hasAttribute('required')).toBe(false);
+ rerender(
+
+ {(control) => {}} />}
+ ,
+ );
+ expect(input.id).toBe(id);
+ expect(input.getAttribute('aria-describedby')).toBe(`external ${id}-hint ${id}-error`);
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ expect(document.getElementById(`${id}-error`)?.textContent).toBe('Invalid URL');
+});
+
+it('keeps surviving dynamic field identities when an earlier row is removed', () => {
+ const fields = (keys: number[]) =>
+ keys.map((key) => (
+
+ {(control) => {}} />}
+
+ ));
+ const { rerender } = render(<>{fields([1, 2])}>);
+ const survivingId = screen.getByLabelText('Header 2').id;
+ expect(screen.getByLabelText('Header 1').id).not.toBe(survivingId);
+ rerender(<>{fields([2])}>);
+ expect(screen.getByLabelText('Header 2').id).toBe(survivingId);
+});
diff --git a/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx b/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx
index 917d79dec66..21e72a715e1 100644
--- a/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx
+++ b/apps/desktop/src/renderer/components/ui/__tests__/input.test.tsx
@@ -25,7 +25,7 @@ describe('Input', () => {
expect(cls).toContain('text-[var(--text-primary)]');
expect(cls).toContain('placeholder:text-[var(--text-placeholder)]');
expect(cls).toContain('border-[var(--border-default)]');
- expect(cls).toContain('focus:ring-[var(--focus-ring)]');
+ expect(cls).toContain('focus:ring-[var(--focus-ring-soft)]');
expect(cls).not.toContain('settings-input-placeholder');
});
@@ -38,7 +38,7 @@ describe('Input', () => {
expect(inputClass()).toContain('h-[40px]');
});
- it('keeps ivory as an explicit registered-debt variant', () => {
+ it('keeps ivory as an explicit white-panel variant', () => {
render( {}} surface="ivory" />);
expect(inputClass()).toContain('bg-[var(--settings-input-bg)]');
expect(inputClass()).not.toContain('bg-[var(--surface-elevated)]');
diff --git a/apps/desktop/src/renderer/components/ui/button.tsx b/apps/desktop/src/renderer/components/ui/button.tsx
index edb69206e20..e0782c083f3 100644
--- a/apps/desktop/src/renderer/components/ui/button.tsx
+++ b/apps/desktop/src/renderer/components/ui/button.tsx
@@ -11,12 +11,13 @@
* 禁用态指针遵循既有「禁用统一普通指针」裁决(#3246):class 仍写
* disabled:cursor-not-allowed,globals.css 把它收成普通箭头。
*
- * loading 首批调用点未用到,本张不做。
+ * loading 仅表达调用方持有的进行中状态;不执行请求或改变提交语义。
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
+import { Spinner } from './spinner';
export type ButtonVariant = 'primary' | 'secondary' | 'cta';
export type ButtonSize = 'md' | 'lg';
@@ -24,6 +25,8 @@ export type ButtonSize = 'md' | 'lg';
export interface ButtonProps extends React.ButtonHTMLAttributes {
variant?: ButtonVariant;
size?: ButtonSize;
+ /** Busy actions retain their label and width, and cannot be activated again. */
+ loading?: boolean;
}
const SIZE_STYLES: Record = {
@@ -55,13 +58,26 @@ const VARIANT_STYLES: Record = {
};
const Button = React.forwardRef(
- ({ className, variant = 'primary', size = 'md', type = 'button', disabled, ...props }, ref) => (
+ (
+ {
+ className,
+ variant = 'primary',
+ size = 'md',
+ type = 'button',
+ disabled,
+ loading = false,
+ children,
+ ...props
+ },
+ ref,
+ ) => (
),
);
Button.displayName = 'Button';
diff --git a/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx b/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx
index ac468edf2b5..e8de7486895 100644
--- a/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx
+++ b/apps/desktop/src/renderer/components/ui/confirm-dialog-provider.tsx
@@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState, ty
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
export interface ConfirmOptions {
+ presentation?: 'standard';
title: string;
description?: string;
/** 可选的标题与正文样式;仅调用方显式传入时生效。 */
@@ -273,6 +274,7 @@ export function ConfirmDialogProvider({ children }: { children: ReactNode }) {
{children}
{currentItem && (
void;
title: string;
@@ -89,6 +92,7 @@ export interface ConfirmDialogProps {
export function ConfirmDialog({
open,
+ presentation,
onOpenChange,
title,
description,
@@ -306,71 +310,104 @@ export function ConfirmDialog({
/>
+ )}
diff --git a/apps/desktop/src/renderer/components/ui/form-field.tsx b/apps/desktop/src/renderer/components/ui/form-field.tsx
new file mode 100644
index 00000000000..ae8ec590b23
--- /dev/null
+++ b/apps/desktop/src/renderer/components/ui/form-field.tsx
@@ -0,0 +1,86 @@
+import { useId, type ReactNode } from 'react';
+
+import { cn } from '@/lib/utils';
+
+export interface FormFieldControlProps {
+ id: string;
+ 'aria-describedby'?: string;
+ 'aria-invalid'?: true;
+ 'aria-required'?: true;
+ error: boolean;
+}
+
+export interface FormFieldProps {
+ id?: string;
+ label: ReactNode;
+ hint?: ReactNode;
+ error?: ReactNode;
+ required?: boolean;
+ /** Existing descriptions outside this field remain associated. */
+ describedBy?: string;
+ labelAction?: ReactNode;
+ className?: string;
+ /** Dense dynamic rows can retain a label for assistive technology. */
+ hideLabel?: boolean;
+ /** Reserve feedback space only for fields that can fail validation. */
+ reserveFeedback?: boolean;
+ children: (control: FormFieldControlProps) => ReactNode;
+}
+
+/** Label/description/error relationships only; values and validation belong to the form. */
+export function FormField({
+ id,
+ label,
+ hint,
+ error,
+ required,
+ describedBy,
+ labelAction,
+ className,
+ hideLabel,
+ reserveFeedback,
+ children,
+}: FormFieldProps) {
+ const generatedId = useId();
+ const controlId = id ?? generatedId;
+ const hintId = `${controlId}-hint`;
+ const errorId = `${controlId}-error`;
+ const description = [describedBy, hint && hintId, error && errorId].filter(Boolean).join(' ');
+ return (
+
+ );
+}
diff --git a/apps/desktop/src/renderer/components/ui/input.tsx b/apps/desktop/src/renderer/components/ui/input.tsx
index 31c501ac39c..7fb3b64bd80 100644
--- a/apps/desktop/src/renderer/components/ui/input.tsx
+++ b/apps/desktop/src/renderer/components/ui/input.tsx
@@ -5,11 +5,10 @@
* 圆角:单行一律胶囊(§5);textarea 变体一律 8px。
* placeholder 一律 `--text-placeholder`(§4 G3)。
*
- * `surface="ivory"` 是登记债(DS-4 G6,拍板人 = 用户/设计师,2026-09-03):
- * colors.ts 的无文档漂移,仅供白弹窗面板场合。独立议题收口,本张不翻案。
+ * `surface="ivory"` 已在 DS-6 D2 确认用于白面板,默认仍为 elevated。
* API 原样搬:secret 眼睛显形、mono、trailing。
*
- * FormField(label + 说明 + 错误行)首批调用点未用到,本张不建。
+ * 字段的 label / 说明 / 错误关系由 FormField 组合,业务校验由消费者持有。
*/
import {
@@ -23,6 +22,7 @@ import { Eye, EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
+import { Tip } from './tooltip';
export type InputSize = 'sm' | 'md' | 'lg';
export type InputSurface = 'elevated' | 'ivory';
@@ -31,10 +31,9 @@ export type InputSurface = 'elevated' | 'ivory';
* 底色:默认 `elevated` = DESIGN.md §4 `input/text` 规定的 fill(`--surface-elevated`)。
*
* `ivory` 是给「输入压在白色弹窗面板上」的调用点保留的既有底色(`--settings-input-bg`,
- * 解析到 `--surface-card-ivory`)。这份 ivory 在设计文档里没有任何背书,属 colors.ts 的
- * 无文档漂移;它在白面板上能给出 fill 抬升,所以不在本 PR 里翻成白色,但也不能当默认——
+ * 解析到 `--surface-card-ivory`)。DS-6 D2 保留其明确用途和用户局部覆盖;
* settings 卡片本身就是 ivory(`--settings-theme-card-bg`),ivory 输入压在 ivory 卡上会和
- * 背景同色,填充对比度归零。settings 域这处 ivory / elevated 的收口是独立议题。
+ * 背景同色,因此不要把 ivory 当成通用默认值。
*/
const SURFACE_STYLES: Record