fix: keep full-window dialogs responsive to mouse input
Render feedback, parsing diagnostics, knowledge confirmations, WeChat binding and image preview overlays through body portals so native titlebar drag regions cannot intercept their controls. Preserve themed form styles and modal focus isolation. Validation: 320 targeted tests, typecheck and lint passed. Native Windows hover and close checks passed for feedback and parsing diagnostics in light and dark themes at two sizes. Full suite timed out without a summary. Release note: 修复反馈等弹窗与窗口标题栏重叠时,关闭按钮无法正常悬停和点击的问题。
This commit is contained in:
@@ -393,6 +393,9 @@
|
||||
`34px × 34px` 点击区域。
|
||||
- 发布说明 Modal 与其他应用级 Modal 一样使用整窗遮罩;遮罩必须覆盖主侧栏、右侧内容和
|
||||
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
|
||||
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,并声明
|
||||
`-webkit-app-region: no-drag`,避免页面层叠上下文中的遮罩受 Electron 原生拖动区域影响。
|
||||
保留 React Context、关闭与焦点管理;表单样式不得依赖已移出的页面祖先选择器。
|
||||
- 当前会话存在 Task 时,Task 上下文栏位于标题栏下方并承载任务摘要与详情;没有 Task 时
|
||||
整行隐藏。折叠状态保持为 `46px` 单行表面,与右侧工作栏标签栏等高,左右分隔线必须水平
|
||||
对齐。该栏不重复放置新建入口。
|
||||
|
||||
@@ -156,6 +156,8 @@ Renderer 不预览、不读取也不接收诊断内容或路径。勾选后详
|
||||
### 3.4 可访问性
|
||||
|
||||
- 对话框使用 `role="dialog"`、`aria-modal="true"` 和可见标题。
|
||||
- 整窗遮罩通过 React portal 挂到 `document.body`,遵循
|
||||
[全局遮罩与原生拖动区域规则](../../../UI-DESIGN.md#610-应用顶栏与全局操作)。
|
||||
- 复用 `activateModalFocus()` 与 `trapTabFocus()`,打开后聚焦反馈类型,关闭后恢复入口
|
||||
按钮焦点。
|
||||
- 非提交状态支持 Escape 关闭;提交中保持对话框打开,直到请求完成或到达有界超时。
|
||||
|
||||
@@ -5693,6 +5693,7 @@ describe("App", () => {
|
||||
const imageDialog = await screen.findByRole("dialog", {
|
||||
name: "页面截图.png",
|
||||
});
|
||||
expect(imageDialog.parentElement?.parentElement).toBe(document.body);
|
||||
expect(
|
||||
within(imageDialog).getByRole("img", { name: "页面截图.png" }),
|
||||
).toHaveAttribute("src", imageAttachment.contentUrl);
|
||||
@@ -5700,7 +5701,7 @@ describe("App", () => {
|
||||
name: "关闭图片查看器",
|
||||
});
|
||||
expect(closeViewer).toHaveFocus();
|
||||
expect(document.querySelector("main")?.inert).toBe(true);
|
||||
expect(document.querySelector<HTMLElement>(".app-shell")?.inert).toBe(true);
|
||||
fireEvent.keyDown(closeViewer, { key: "Tab" });
|
||||
expect(
|
||||
within(imageDialog).getByRole("button", {
|
||||
@@ -5712,7 +5713,7 @@ describe("App", () => {
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "页面截图.png" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(document.querySelector("main")?.inert).toBe(false);
|
||||
expect(document.querySelector<HTMLElement>(".app-shell")?.inert).toBe(false);
|
||||
fireEvent.click(
|
||||
within(userArticle).getByRole("button", {
|
||||
name: "下载图片 页面截图.png",
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { knowledgeReferenceKey } from "../../shared/knowledge-reference";
|
||||
import type { TFunction } from "i18next";
|
||||
import type {
|
||||
@@ -10928,7 +10929,7 @@ function App(): React.JSX.Element {
|
||||
reference={citationDialog.reference}
|
||||
/>
|
||||
)}
|
||||
{imageViewerItem && (
|
||||
{imageViewerItem && createPortal(
|
||||
<div
|
||||
className="image-viewer-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
@@ -10980,7 +10981,8 @@ function App(): React.JSX.Element {
|
||||
<img alt={imageViewerItem.title} src={imageViewerItem.src} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{customTaskDialog && activeProject?.kind === "user" && (
|
||||
<CustomTaskDialog
|
||||
|
||||
@@ -363,6 +363,9 @@ describe('ChannelSettingsSection', () => {
|
||||
const close = await screen.findByRole('button', {
|
||||
name: '关闭微信绑定'
|
||||
})
|
||||
expect(screen.getByRole('dialog').parentElement?.parentElement).toBe(
|
||||
document.body
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'请在微信中依次打开“设置 → 插件 → ClawBot → 开始扫一扫”,扫描下方二维码。二维码不会发送到第三方页面。'
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import QRCode from 'qrcode'
|
||||
import type {
|
||||
ChannelConnectionTestResult,
|
||||
@@ -512,7 +513,7 @@ function WeixinQrDialog({
|
||||
)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="channel-qr-backdrop">
|
||||
<section
|
||||
aria-labelledby="channel-qr-title"
|
||||
@@ -639,7 +640,8 @@ function WeixinQrDialog({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -513,7 +513,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
})
|
||||
|
||||
it('runs a real-file diagnostic flow and displays its result', async () => {
|
||||
render(<DocumentParsingSettingsSection />)
|
||||
const { container } = render(<DocumentParsingSettingsSection />)
|
||||
await screen.findByText('PP-OCRv6 Tiny')
|
||||
|
||||
const trigger = screen.getByRole('button', {
|
||||
@@ -525,15 +525,13 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: '解析测试结果'
|
||||
})
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body)
|
||||
expect(dialog).toHaveTextContent('扫描件识别正文')
|
||||
const close = screen.getByRole('button', {
|
||||
name: '关闭结果'
|
||||
})
|
||||
await waitFor(() => expect(close).toHaveFocus())
|
||||
const backgroundSection = trigger.closest<HTMLElement>(
|
||||
'.settings-section'
|
||||
)
|
||||
expect(backgroundSection?.inert).toBe(true)
|
||||
expect(container.inert).toBe(true)
|
||||
expect(
|
||||
fireEvent.keyDown(dialog, { key: 'Tab' })
|
||||
).toBe(false)
|
||||
@@ -544,7 +542,7 @@ describe('DocumentParsingSettingsSection', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
)
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(backgroundSection?.inert).toBe(false)
|
||||
expect(container.inert).toBe(false)
|
||||
expect(test).toHaveBeenCalledWith('chat-attachment')
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type {
|
||||
DocumentParsingDiagnostic,
|
||||
DocumentOcrModelCatalogViewEntry,
|
||||
@@ -104,7 +105,7 @@ function DiagnosticDialog({
|
||||
useEffect(() => {
|
||||
return activateModalFocus(() => closeRef.current)
|
||||
}, [])
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
className="document-parsing-diagnostic-backdrop"
|
||||
onMouseDown={(event) => {
|
||||
@@ -190,7 +191,8 @@ function DiagnosticDialog({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,7 @@ describe('FeedbackDialog', () => {
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '提交反馈'
|
||||
})
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
const category = screen.getByLabelText('反馈类型')
|
||||
expect(category).toHaveFocus()
|
||||
fireEvent.change(screen.getByLabelText('标题'), {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { AppInfo } from '../../shared/contracts'
|
||||
import {
|
||||
feedbackCategories,
|
||||
@@ -326,7 +327,7 @@ export function FeedbackDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
@@ -760,6 +761,7 @@ export function FeedbackDialog({
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -385,6 +385,7 @@ describe('KnowledgeWorkspace', () => {
|
||||
'将删除此来源的 1 篇文档、检索索引、图谱证据和应用托管副本。磁盘上的原始文件不会改变。'
|
||||
)
|
||||
expect(onRemoveSource).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alertdialog').parentElement).toBe(document.body)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '移除来源' })
|
||||
)
|
||||
@@ -1920,7 +1921,9 @@ describe('KnowledgeWorkspace', () => {
|
||||
'将永久删除实体“GoodBuddy”及其 1 条关联关系;相关证据引用也会从图谱中移除,且无法恢复。'
|
||||
)
|
||||
expect(onDeleteEntity).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('alertdialog').parentElement).toBe(document.body)
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '删除关系 使用' })
|
||||
@@ -2086,7 +2089,7 @@ describe('KnowledgeWorkspace', () => {
|
||||
})
|
||||
|
||||
it('isolates and traps focus in the library edit dialog', async () => {
|
||||
render(<KnowledgeWorkspace {...createProps()} />)
|
||||
const { container } = render(<KnowledgeWorkspace {...createProps()} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '编辑' })
|
||||
trigger.focus()
|
||||
@@ -2095,13 +2098,10 @@ describe('KnowledgeWorkspace', () => {
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '编辑知识库'
|
||||
})
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
const nameInput = screen.getByLabelText('名称')
|
||||
expect(nameInput).toHaveFocus()
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(true)
|
||||
expect(container.inert).toBe(true)
|
||||
fireEvent.keyDown(nameInput, { key: 'Tab', shiftKey: true })
|
||||
expect(
|
||||
screen.getByRole('button', { name: '保存修改' })
|
||||
@@ -2109,16 +2109,12 @@ describe('KnowledgeWorkspace', () => {
|
||||
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(false)
|
||||
expect(container.inert).toBe(false)
|
||||
})
|
||||
|
||||
it('confirms that deleting a managed library removes managed copies', async () => {
|
||||
const onDeleteLibrary = vi.fn()
|
||||
render(
|
||||
const { container } = render(
|
||||
<KnowledgeWorkspace
|
||||
{...createProps({ onDeleteLibrary })}
|
||||
/>
|
||||
@@ -2131,19 +2127,12 @@ describe('KnowledgeWorkspace', () => {
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: '删除知识库确认'
|
||||
})
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
expect(screen.getByRole('button', { name: '取消' })).toHaveFocus()
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(true)
|
||||
expect(container.inert).toBe(true)
|
||||
fireEvent.keyDown(dialog, { key: 'Escape' })
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(
|
||||
document.querySelector<HTMLElement>(
|
||||
'.knowledge-workspace__main'
|
||||
)?.inert
|
||||
).toBe(false)
|
||||
expect(container.inert).toBe(false)
|
||||
fireEvent.click(trigger)
|
||||
expect(
|
||||
screen.getByText(
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { TFunction } from 'i18next'
|
||||
import type {
|
||||
KnowledgeDocumentItem as SharedKnowledgeDocumentItem,
|
||||
@@ -890,7 +891,7 @@ function EditLibraryDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label={t('edit.ariaLabel')}
|
||||
aria-modal="true"
|
||||
@@ -961,7 +962,8 @@ function EditLibraryDialog({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -998,7 +1000,7 @@ function DeleteLibraryDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-label={t('delete.ariaLabel')}
|
||||
aria-modal="true"
|
||||
@@ -1056,7 +1058,8 @@ function DeleteLibraryDialog({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1097,7 +1100,7 @@ function RemoveSourceDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
@@ -1155,7 +1158,8 @@ function RemoveSourceDialog({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1248,7 +1252,7 @@ function GraphDestructiveDialog({
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
@@ -1296,7 +1300,8 @@ function GraphDestructiveDialog({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6955,12 +6955,13 @@ button > svg {
|
||||
}
|
||||
|
||||
.image-viewer-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
display: grid;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
inset: 38px 0 0;
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@@ -9935,12 +9936,13 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.channel-qr-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
padding: var(--space-4);
|
||||
background: var(--overlay-backdrop);
|
||||
inset: 38px 0 0;
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@@ -10060,6 +10062,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.feedback-dialog-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -10749,6 +10752,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.document-parsing-diagnostic-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -12794,6 +12798,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-confirm-dialog-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -12836,6 +12841,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
}
|
||||
|
||||
.knowledge-library-dialog-backdrop {
|
||||
-webkit-app-region: no-drag;
|
||||
position: fixed;
|
||||
z-index: var(--z-dialog);
|
||||
display: grid;
|
||||
@@ -12890,7 +12896,7 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.knowledge-page
|
||||
:is(.knowledge-page, .knowledge-library-dialog)
|
||||
:is(
|
||||
input:not([type]),
|
||||
input[type='text'],
|
||||
@@ -15954,7 +15960,15 @@ details.settings-section > :not(summary) + :not(summary) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .app-shell strong {
|
||||
:root[data-theme='dark'] :is(
|
||||
.app-shell,
|
||||
.feedback-dialog,
|
||||
.document-parsing-diagnostic,
|
||||
.knowledge-library-dialog,
|
||||
.knowledge-confirm-dialog,
|
||||
.channel-qr-dialog,
|
||||
.image-viewer-dialog
|
||||
) strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user